docs: complete XML doc comments via fixdocs (2757 to 131 findings)

Add missing <returns>/<param>/<summary>/<typeparam> tags and clean up
misused inheritdoc across 481 files so the documented API surface is
complete. Documentation-only (zero code lines changed). The 131 remaining
findings are inheritdoc-style warnings deliberately left to preserve
hand-written implementation rationale (plan-decision notes, race-condition
explanations).
This commit is contained in:
Joseph Doherty
2026-06-03 12:34:34 -04:00
parent c6d9b20d9f
commit bd6c0b4d3d
481 changed files with 2550 additions and 1668 deletions
@@ -9,6 +9,8 @@ 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>
/// <param name="x">The X input address string to parse.</param>
/// <param name="expected">The expected discrete coil address after parsing.</param>
[Theory]
[InlineData("X0", (ushort)0)]
[InlineData("X9", (ushort)9)]
@@ -22,6 +24,8 @@ public sealed class MelsecAddressTests
=> MelsecAddress.XInputToDiscrete(x, MelsecFamily.Q_L_iQR).ShouldBe(expected);
/// <summary>Verifies that F-series and iQF family X inputs parse as octal.</summary>
/// <param name="x">The X input address string to parse.</param>
/// <param name="expected">The expected discrete coil address after parsing.</param>
[Theory]
[InlineData("X0", (ushort)0)]
[InlineData("X7", (ushort)7)]
@@ -32,6 +36,8 @@ public sealed class MelsecAddressTests
=> MelsecAddress.XInputToDiscrete(x, MelsecFamily.F_iQF).ShouldBe(expected);
/// <summary>Verifies that Q-series and iQR family Y outputs parse as hexadecimal.</summary>
/// <param name="y">The Y output address string to parse.</param>
/// <param name="expected">The expected coil address after parsing.</param>
[Theory]
[InlineData("Y0", (ushort)0)]
[InlineData("Y1F", (ushort)31)]
@@ -39,6 +45,8 @@ public sealed class MelsecAddressTests
=> MelsecAddress.YOutputToCoil(y, MelsecFamily.Q_L_iQR).ShouldBe(expected);
/// <summary>Verifies that F-series and iQF family Y outputs parse as octal.</summary>
/// <param name="y">The Y output address string to parse.</param>
/// <param name="expected">The expected coil address after parsing.</param>
[Theory]
[InlineData("Y0", (ushort)0)]
[InlineData("Y17", (ushort)15)]
@@ -58,6 +66,7 @@ public sealed class MelsecAddressTests
}
/// <summary>Verifies that non-octal X input addresses are rejected for F-series and iQF families.</summary>
/// <param name="bad">An invalid X input address string that should be rejected.</param>
[Theory]
[InlineData("X8")] // 8 is non-octal
[InlineData("X12G")] // G is non-hex
@@ -65,6 +74,7 @@ public sealed class MelsecAddressTests
=> 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>
/// <param name="bad">An invalid X input address string that should be rejected.</param>
[Theory]
[InlineData("X12G")]
public void XInputToDiscrete_QLiQR_rejects_non_hex(string bad)
@@ -84,6 +94,8 @@ public sealed class MelsecAddressTests
// --- M-relay (decimal, both families) ---
/// <summary>Verifies that M relay addresses parse as decimal.</summary>
/// <param name="m">The M relay address string to parse.</param>
/// <param name="expected">The expected coil address after parsing.</param>
[Theory]
[InlineData("M0", (ushort)0)]
[InlineData("M10", (ushort)10)] // M addresses are DECIMAL, not hex or octal
@@ -109,6 +121,8 @@ public sealed class MelsecAddressTests
// --- D-register (decimal, both families) ---
/// <summary>Verifies that D register addresses parse as decimal.</summary>
/// <param name="d">The D register address string to parse.</param>
/// <param name="expected">The expected holding register address after parsing.</param>
[Theory]
[InlineData("D0", (ushort)0)]
[InlineData("D100", (ushort)100)]
@@ -22,6 +22,7 @@ public sealed class ModbusArrayTests
}
/// <summary>Verifies reading an Int16 array returns a typed array.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Read_Int16_Array_Returns_Typed_Array()
{
@@ -36,6 +37,7 @@ public sealed class ModbusArrayTests
}
/// <summary>Verifies reading a Float32 array with word swap returns a typed array.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Read_Float32_Array_Returns_Typed_Array_With_WordSwap()
{
@@ -63,6 +65,7 @@ public sealed class ModbusArrayTests
}
/// <summary>Verifies reading a coil array returns a bool array.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Read_Coil_Array_Returns_Bool_Array()
{
@@ -78,6 +81,7 @@ public sealed class ModbusArrayTests
}
/// <summary>Verifies writing an Int16 array lands contiguously in the register bank.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Write_Int16_Array_Lands_Contiguous_In_Bank()
{
@@ -96,6 +100,7 @@ public sealed class ModbusArrayTests
}
/// <summary>Verifies writing a coil array packs bits in LSB-first order.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Write_Coil_Array_Packs_LSB_First()
{
@@ -114,6 +119,7 @@ public sealed class ModbusArrayTests
}
/// <summary>Verifies writing an array with mismatched length surfaces an error.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Write_Array_Mismatch_Length_Surfaces_Error()
{
@@ -129,6 +135,7 @@ public sealed class ModbusArrayTests
}
/// <summary>Verifies discovery surfaces IsArray and ArrayDim correctly.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Discovery_Surfaces_IsArray_And_ArrayDim()
{
@@ -145,6 +152,7 @@ public sealed class ModbusArrayTests
}
/// <summary>Verifies scalar tag discovery keeps IsArray false.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Scalar_Tag_Discovery_Stays_NonArray()
{
@@ -164,39 +172,27 @@ public sealed class ModbusArrayTests
/// <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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
=> throw new NotSupportedException("RecordingBuilder doesn't model alarms");
}
@@ -14,16 +14,10 @@ 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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
Pdus.Add(pdu);
@@ -71,6 +65,7 @@ public sealed class ModbusBitRmwTests
}
/// <summary>Verifies that setting a bit reads the current register, ORs the bit, and writes back.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Bit_set_reads_current_register_ORs_bit_writes_back()
{
@@ -90,6 +85,7 @@ public sealed class ModbusBitRmwTests
}
/// <summary>Verifies that clearing a bit reads the current register, ANDs the bit off, and writes back.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Bit_clear_reads_current_register_ANDs_bit_off_writes_back()
{
@@ -104,6 +100,7 @@ public sealed class ModbusBitRmwTests
}
/// <summary>Verifies that concurrent bit writes to the same register preserve all updates via serialization.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Concurrent_bit_writes_to_same_register_preserve_all_updates()
{
@@ -123,6 +120,7 @@ public sealed class ModbusBitRmwTests
}
/// <summary>Verifies that bit writes to different registers proceed in parallel without contention.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Bit_write_on_different_registers_proceeds_in_parallel_without_contention()
{
@@ -140,6 +138,7 @@ public sealed class ModbusBitRmwTests
}
/// <summary>Verifies that bit writes preserve other bits in the same register.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Bit_write_preserves_other_bits_in_the_same_register()
{
@@ -55,11 +55,13 @@ public sealed class ModbusCapTests
return Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} unsupported"));
}
/// <inheritdoc />
/// <summary>Releases resources used by this transport instance.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>Verifies that a read within the cap issues a single FC03 request.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Read_within_cap_issues_single_FC03_request()
{
@@ -77,6 +79,7 @@ public sealed class ModbusCapTests
}
/// <summary>Verifies that a read above cap splits into two FC03 requests.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Read_above_cap_splits_into_two_FC03_requests()
{
@@ -112,6 +115,7 @@ public sealed class ModbusCapTests
}
/// <summary>Verifies that read cap honors Mitsubishi lower cap of 64 registers.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Read_cap_honors_Mitsubishi_lower_cap_of_64()
{
@@ -131,6 +135,7 @@ public sealed class ModbusCapTests
}
/// <summary>Verifies that write exceeding cap throws instead of splitting.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_exceeding_cap_throws_instead_of_splitting()
{
@@ -155,6 +160,7 @@ public sealed class ModbusCapTests
}
/// <summary>Verifies that write within cap proceeds normally.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_within_cap_proceeds_normally()
{
@@ -49,11 +49,13 @@ public sealed class ModbusCoalescingAutoRecoveryTests
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
/// <inheritdoc />
/// <summary>Releases resources used by this transport instance.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>Verifies that the first failure falls back to per-tag reads in the same scan.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task First_Failure_Falls_Back_To_PerTag_Same_Scan()
{
@@ -82,6 +84,7 @@ public sealed class ModbusCoalescingAutoRecoveryTests
}
/// <summary>Verifies that the second scan skips coalesced reads of prohibited ranges.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Second_Scan_Skips_Coalesced_Read_Of_Prohibited_Range()
{
@@ -112,6 +115,7 @@ public sealed class ModbusCoalescingAutoRecoveryTests
}
/// <summary>Verifies that reprobe clears prohibition when the range becomes healthy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reprobe_Clears_Prohibition_When_Range_Becomes_Healthy()
{
@@ -142,6 +146,7 @@ public sealed class ModbusCoalescingAutoRecoveryTests
}
/// <summary>Verifies that reprobe leaves prohibition in place when the range is still bad.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reprobe_Leaves_Prohibition_When_Range_Is_Still_Bad()
{
@@ -166,6 +171,7 @@ public sealed class ModbusCoalescingAutoRecoveryTests
}
/// <summary>Verifies that GetAutoProhibitedRanges surfaces an operator-visible snapshot.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAutoProhibitedRanges_Surfaces_Operator_Visible_Snapshot()
{
@@ -199,6 +205,7 @@ public sealed class ModbusCoalescingAutoRecoveryTests
}
/// <summary>Verifies that tags outside prohibited ranges still coalesce.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Tags_Outside_Prohibited_Range_Still_Coalesce()
{
@@ -22,15 +22,9 @@ public sealed class ModbusCoalescingBisectionTests
{
/// <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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
@@ -54,6 +48,7 @@ public sealed class ModbusCoalescingBisectionTests
}
/// <summary>Verifies that bisection narrows a multi-register prohibition on each reprobe cycle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Bisection_Narrows_Multi_Register_Prohibition_Per_Reprobe()
{
@@ -98,6 +93,7 @@ public sealed class ModbusCoalescingBisectionTests
}
/// <summary>Verifies that the prohibition is cleared when both bisected halves succeed in recovery.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Bisection_Clears_When_Both_Halves_Are_Healthy()
{
@@ -127,6 +123,7 @@ public sealed class ModbusCoalescingBisectionTests
}
/// <summary>Verifies that the prohibition splits into two entries when both bisected halves still fail.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Bisection_Splits_Into_Two_When_Both_Halves_Still_Fail()
{
@@ -160,15 +157,9 @@ 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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
@@ -15,13 +15,9 @@ 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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
@@ -38,11 +34,13 @@ public sealed class ModbusCoalescingTests
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
/// <summary>Disposes the transport asynchronously.</summary>
/// <summary>Disposes the transport; no-op for this in-memory fake.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>Verifies that MaxReadGap=0 defaults to per-tag reads without coalescing.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task MaxReadGap_Zero_Defaults_To_Per_Tag_Reads()
{
@@ -62,6 +60,7 @@ public sealed class ModbusCoalescingTests
}
/// <summary>Verifies that MaxReadGap bridges adjacent tags into a single read.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task MaxReadGap_Bridges_Two_Adjacent_Tags_Into_One_Read()
{
@@ -84,6 +83,7 @@ public sealed class ModbusCoalescingTests
}
/// <summary>Verifies that MaxReadGap splits blocks when gaps exceed threshold.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task MaxReadGap_Splits_When_Gap_Exceeds_Threshold()
{
@@ -104,6 +104,7 @@ public sealed class ModbusCoalescingTests
}
/// <summary>Verifies that tags with CoalesceProhibited are read separately.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task CoalesceProhibited_Tag_Reads_Alone()
{
@@ -125,6 +126,7 @@ public sealed class ModbusCoalescingTests
}
/// <summary>Verifies that coalescing does not cross unit ID boundaries.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Coalescing_Does_Not_Cross_UnitId_Boundaries()
{
@@ -145,6 +147,7 @@ public sealed class ModbusCoalescingTests
}
/// <summary>Verifies that coalescing splits blocks exceeding MaxRegistersPerRead.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Coalescing_Splits_Block_That_Exceeds_MaxRegistersPerRead()
{
@@ -166,6 +169,7 @@ public sealed class ModbusCoalescingTests
}
/// <summary>Verifies that coalesced reads surface each tag value independently.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Coalesced_Read_Surfaces_Each_Tag_Value_Independently()
{
@@ -24,15 +24,11 @@ public sealed class ModbusDriverTests
/// <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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var fc = pdu[0];
@@ -112,6 +108,7 @@ public sealed class ModbusDriverTests
}
/// <summary>Disposes the transport asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
@@ -124,6 +121,7 @@ public sealed class ModbusDriverTests
}
/// <summary>Verifies that Initialize connects and populates the tag map.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Initialize_connects_and_populates_tag_map()
{
@@ -135,6 +133,7 @@ public sealed class ModbusDriverTests
}
/// <summary>Verifies that reading Int16 holding registers returns big-endian values correctly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Read_Int16_holding_register_returns_BigEndian_value()
{
@@ -148,6 +147,7 @@ public sealed class ModbusDriverTests
}
/// <summary>Verifies that reading Float32 values spans two registers in big-endian format.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Read_Float32_spans_two_registers_BigEndian()
{
@@ -165,6 +165,7 @@ public sealed class ModbusDriverTests
}
/// <summary>Verifies that reading coils returns boolean values.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Read_Coil_returns_boolean()
{
@@ -177,6 +178,7 @@ public sealed class ModbusDriverTests
}
/// <summary>Verifies that reading unknown tags returns BadNodeIdUnknown status instead of throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Unknown_tag_returns_BadNodeIdUnknown_not_an_exception()
{
@@ -188,6 +190,7 @@ public sealed class ModbusDriverTests
}
/// <summary>Verifies that writing UInt16 holding registers round-trips correctly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_UInt16_holding_register_roundtrips()
{
@@ -200,6 +203,7 @@ public sealed class ModbusDriverTests
}
/// <summary>Verifies that writing Float32 values uses function code 16 (WriteMultipleRegisters).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_Float32_uses_FC16_WriteMultipleRegisters()
{
@@ -218,6 +222,7 @@ public sealed class ModbusDriverTests
}
/// <summary>Verifies that writing to input registers returns BadNotWritable status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_to_InputRegister_returns_BadNotWritable()
{
@@ -229,6 +234,7 @@ public sealed class ModbusDriverTests
}
/// <summary>Verifies that Discover streams one folder per driver with a variable per tag.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Discover_streams_one_folder_per_driver_with_a_variable_per_tag()
{
@@ -250,6 +256,7 @@ public sealed class ModbusDriverTests
}
/// <summary>Verifies that Discover propagates WriteIdempotent from tag to attribute info.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Discover_propagates_WriteIdempotent_from_tag_to_attribute_info()
{
@@ -278,41 +285,31 @@ public sealed class ModbusDriverTests
/// <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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
public string FullReference => fullRef;
/// <summary>Marks this variable as an alarm condition.</summary>
/// <param name="info">Alarm condition information.</param>
/// <inheritdoc />
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>
/// <inheritdoc />
public void OnTransition(AlarmEventArgs args) { }
}
}
@@ -33,22 +33,20 @@ public sealed class ModbusExceptionMapperTests
/// <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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>Verifies that exception 0x02 surfaces as BadOutOfRange, not BadInternalError.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Read_surface_exception_02_as_BadOutOfRange_not_BadInternalError()
{
@@ -63,6 +61,7 @@ public sealed class ModbusExceptionMapperTests
}
/// <summary>Verifies that exception 0x04 surfaces as BadDeviceFailure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_surface_exception_04_as_BadDeviceFailure()
{
@@ -82,22 +81,20 @@ public sealed class ModbusExceptionMapperTests
/// <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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
=> Task.FromException<byte[]>(new EndOfStreamException("socket closed mid-response"));
/// <summary>Completes immediately.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>Verifies that non-Modbus transport failures surface as BadCommunicationError.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Read_non_modbus_failure_maps_to_BadCommunicationError_not_BadInternalError()
{
@@ -23,14 +23,10 @@ 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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
Interlocked.Increment(ref SendCount);
@@ -71,7 +67,8 @@ public sealed class ModbusLifecycleHygieneTests
}
}
/// <summary>Disposes the transport asynchronously.</summary>
/// <summary>Releases the transport and increments the dispose counter.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() { Interlocked.Increment(ref DisposeCount); return ValueTask.CompletedTask; }
}
@@ -87,6 +84,7 @@ public sealed class ModbusLifecycleHygieneTests
// -------------------- Finding -002 / -012 (2) --------------------
/// <summary>Verifies that reinitialization clears stale tag cache entries.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reinitialize_clears_stale_tagsByName_entries()
{
@@ -108,6 +106,7 @@ public sealed class ModbusLifecycleHygieneTests
}
/// <summary>Verifies that reinitialization clears the deadband and write-suppression caches.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reinitialize_clears_lastPublished_and_lastWritten_caches()
{
@@ -149,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>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisposeAsync_without_explicit_Shutdown_tears_down_probe_loop_and_transport()
{
@@ -187,6 +187,7 @@ public sealed class ModbusLifecycleHygieneTests
}
/// <summary>Verifies that DisposeAsync disposes the poll engine so subscriptions stop.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisposeAsync_disposes_the_pollEngine_so_subscriptions_stop()
{
@@ -232,13 +233,9 @@ public sealed class ModbusLifecycleHygieneTests
/// <summary>How many bytes to return — anything &lt; 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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var resp = new byte[ResponseBytes];
@@ -246,11 +243,13 @@ 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>
/// <summary>Releases the transport; this implementation is a no-op.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>Verifies that short response PDUs surface as BadCommunicationError, not IndexOutOfRangeException.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Short_response_PDU_surfaces_as_BadCommunicationError_not_an_IndexOutOfRangeException()
{
@@ -269,6 +268,7 @@ public sealed class ModbusLifecycleHygieneTests
}
/// <summary>Verifies that response payloads truncated below declared byte count surface as BadCommunicationError.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Response_payload_truncated_below_declared_byteCount_surfaces_as_BadCommunicationError()
{
@@ -317,16 +317,13 @@ 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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <summary>Releases the transport; this implementation is a no-op.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
@@ -340,6 +337,7 @@ public sealed class ModbusLifecycleHygieneTests
/// record so reference-assignment atomicity already prevents tearing; the test guards
/// against future regressions to a struct-typed health surface).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetHealth_under_concurrent_pressure_always_returns_a_complete_snapshot()
{
@@ -42,7 +42,7 @@ public sealed class ModbusLoggerInjectionTests
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
/// <inheritdoc />
/// <summary>Disposes the scope (no-op).</summary>
public void Dispose() { }
}
}
@@ -52,15 +52,9 @@ public sealed class ModbusLoggerInjectionTests
{
/// <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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
@@ -71,12 +65,13 @@ public sealed class ModbusLoggerInjectionTests
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
/// <summary>Disposes the transport asynchronously.</summary>
/// <summary>Disposes the test transport stub.</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>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task First_Failure_Emits_Single_Warning_Subsequent_Refire_Stays_Quiet()
{
@@ -108,6 +103,7 @@ public sealed class ModbusLoggerInjectionTests
}
/// <summary>Verifies reprobe clearing prohibition emits information log.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reprobe_Clearing_Prohibition_Emits_Information_Log()
{
@@ -14,13 +14,9 @@ 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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
SeenUnitIds.Add(unitId);
@@ -36,11 +32,13 @@ public sealed class ModbusMultiUnitTests
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
/// <summary>Disposes the transport resources.</summary>
/// <summary>Releases the transport; this implementation is a no-op.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>Verifies that per-tag UnitId routes reads to the correct slave.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task PerTag_UnitId_Routes_To_Correct_Slave_In_MBAP()
{
@@ -61,6 +59,7 @@ public sealed class ModbusMultiUnitTests
}
/// <summary>Verifies that tags without UnitId override use the driver-level UnitId.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Tag_Without_UnitId_Falls_Back_To_DriverLevel()
{
@@ -77,6 +76,7 @@ public sealed class ModbusMultiUnitTests
}
/// <summary>Verifies that IPerCallHostResolver returns per-slave host strings.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task IPerCallHostResolver_Returns_Per_Slave_Host_String()
{
@@ -97,6 +97,7 @@ public sealed class ModbusMultiUnitTests
}
/// <summary>Verifies that IPerCallHostResolver falls back to hostname for unknown tags.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task IPerCallHostResolver_Unknown_Tag_Falls_Back_To_HostName()
{
@@ -19,16 +19,10 @@ 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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (pdu[0] == 0x03) Interlocked.Increment(ref ProbeCount);
@@ -60,6 +54,7 @@ public sealed class ModbusProbeTests
}
/// <summary>Verifies that the initial state is Unknown before the first probe tick.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Initial_state_is_Unknown_before_first_probe_tick()
{
@@ -73,6 +68,7 @@ public sealed class ModbusProbeTests
}
/// <summary>Verifies that the first successful probe transitions the state to Running.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task First_successful_probe_transitions_to_Running()
{
@@ -105,6 +101,7 @@ public sealed class ModbusProbeTests
}
/// <summary>Verifies that a transport failure transitions the state to Stopped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Transport_failure_transitions_to_Stopped()
{
@@ -128,6 +125,7 @@ public sealed class ModbusProbeTests
}
/// <summary>Verifies that recovery transitions the state from Stopped back to Running.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Recovery_transitions_Stopped_back_to_Running()
{
@@ -155,6 +153,7 @@ public sealed class ModbusProbeTests
}
/// <summary>Verifies that repeated successful probes do not generate duplicate Running events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Repeated_successful_probes_do_not_generate_duplicate_Running_events()
{
@@ -176,6 +175,7 @@ public sealed class ModbusProbeTests
}
/// <summary>Verifies that a disabled probe stays Unknown and fires no events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Disabled_probe_stays_Unknown_and_fires_no_events()
{
@@ -192,6 +192,7 @@ public sealed class ModbusProbeTests
}
/// <summary>Verifies that shutdown stops the probe loop.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Shutdown_stops_the_probe_loop()
{
@@ -15,15 +15,9 @@ 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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
Sent.Add(pdu);
@@ -70,6 +64,7 @@ public sealed class ModbusProtocolOptionsTests
}
/// <summary>Verifies that single coil write uses FC05 by default.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Single_Coil_Write_Uses_FC05_By_Default()
{
@@ -84,6 +79,7 @@ public sealed class ModbusProtocolOptionsTests
}
/// <summary>Verifies that single coil write uses FC15 when forced.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Single_Coil_Write_Uses_FC15_When_Forced()
{
@@ -99,6 +95,7 @@ public sealed class ModbusProtocolOptionsTests
}
/// <summary>Verifies that single register write uses FC06 by default.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Single_Register_Write_Uses_FC06_By_Default()
{
@@ -113,6 +110,7 @@ public sealed class ModbusProtocolOptionsTests
}
/// <summary>Verifies that single register write uses FC16 when forced.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Single_Register_Write_Uses_FC16_When_Forced()
{
@@ -128,6 +126,7 @@ public sealed class ModbusProtocolOptionsTests
}
/// <summary>Verifies that coil array read automatically chunks at MaxCoilsPerRead.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Coil_Array_Read_Auto_Chunks_At_MaxCoilsPerRead()
{
@@ -20,13 +20,9 @@ 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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
switch (pdu[0])
@@ -52,10 +48,12 @@ public sealed class ModbusSubscribeOptionsTests
}
}
/// <summary>Disposes the transport (no-op for testing).</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>Verifies that deadband filter suppresses changes below the threshold.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Deadband_Suppresses_SubThreshold_Changes()
{
@@ -97,6 +95,7 @@ public sealed class ModbusSubscribeOptionsTests
}
/// <summary>Verifies that null deadband publishes every value change.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Deadband_Null_Publishes_Every_Change()
{
@@ -122,6 +121,7 @@ public sealed class ModbusSubscribeOptionsTests
}
/// <summary>Verifies that WriteOnChangeOnly suppresses writes with identical repeated values.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task WriteOnChangeOnly_Suppresses_Identical_Repeated_Writes()
{
@@ -141,6 +141,7 @@ public sealed class ModbusSubscribeOptionsTests
}
/// <summary>Verifies that WriteOnChangeOnly defaults to false and writes all values.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task WriteOnChangeOnly_Default_False_Always_Writes()
{
@@ -159,6 +160,7 @@ public sealed class ModbusSubscribeOptionsTests
}
/// <summary>Verifies that external reads invalidate the WriteOnChangeOnly cache.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task WriteOnChangeOnly_Cache_Invalidated_By_Read_Divergence()
{
@@ -18,14 +18,10 @@ public sealed class ModbusSubscriptionTests
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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (pdu[0] != 0x03) return Task.FromException<byte[]>(new NotSupportedException("FC not supported"));
@@ -41,7 +37,8 @@ public sealed class ModbusSubscriptionTests
}
return Task.FromResult(resp);
}
/// <summary>Disposes the transport asynchronously.</summary>
/// <summary>Disposes the fake transport. No-op in this test double.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
@@ -53,6 +50,7 @@ public sealed class ModbusSubscriptionTests
}
/// <summary>Verifies initial poll raises OnDataChange for every subscribed tag.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Initial_poll_raises_OnDataChange_for_every_subscribed_tag()
{
@@ -75,6 +73,7 @@ public sealed class ModbusSubscriptionTests
}
/// <summary>Verifies unchanged values do not raise after initial poll.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Unchanged_values_do_not_raise_after_initial_poll()
{
@@ -93,6 +92,7 @@ public sealed class ModbusSubscriptionTests
}
/// <summary>Verifies value change between polls raises OnDataChange.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Value_change_between_polls_raises_OnDataChange()
{
@@ -114,6 +114,7 @@ public sealed class ModbusSubscriptionTests
}
/// <summary>Verifies unsubscribe stops the polling loop.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Unsubscribe_stops_the_polling_loop()
{
@@ -134,6 +135,7 @@ public sealed class ModbusSubscriptionTests
}
/// <summary>Verifies SubscribeAsync floors intervals below 100ms.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeAsync_floors_intervals_below_100ms()
{
@@ -154,6 +156,7 @@ public sealed class ModbusSubscriptionTests
}
/// <summary>Verifies multiple subscriptions fire independently.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Multiple_subscriptions_fire_independently()
{
@@ -196,6 +199,7 @@ public sealed class ModbusSubscriptionTests
/// value steps up by 5 every poll (well over the deadband of 2) so every poll publishes,
/// maximising contention on the cache.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Concurrent_deadband_subscriptions_do_not_corrupt_the_publish_cache()
{
@@ -107,6 +107,7 @@ public sealed class ModbusTcpReconnectTests
}
/// <summary>Stops the server and releases resources.</summary>
/// <returns>A task that represents the asynchronous dispose operation.</returns>
public async ValueTask DisposeAsync()
{
_stop.Cancel();
@@ -116,6 +117,7 @@ public sealed class ModbusTcpReconnectTests
}
/// <summary>Verifies that the transport recovers from a mid-session socket drop with auto-reconnect enabled.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Transport_recovers_from_mid_session_drop_and_retries_successfully()
{
@@ -136,6 +138,7 @@ public sealed class ModbusTcpReconnectTests
}
/// <summary>Verifies that socket drops propagate to the caller when auto-reconnect is disabled.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Transport_without_AutoReconnect_propagates_drop_to_caller()
{