test: guarantee driver fixture cleanup without masking failures

This commit is contained in:
Joseph Doherty
2026-10-13 16:36:38 -04:00
parent 5310a7e07a
commit eed7cc7d75
14 changed files with 602 additions and 89 deletions
@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Reflection;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
@@ -14,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// unit test alongside them per Driver.Modbus-012.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusLifecycleHygieneTests
public sealed class ModbusLifecycleHygieneTests(ITestOutputHelper output)
{
private sealed class FakeTransport : IModbusTransport
{
@@ -528,7 +529,7 @@ public sealed class ModbusLifecycleHygieneTests
/// <summary>Verifies that DecodeBitArray rejects an empty bitmap with InvalidDataException.</summary>
[Fact]
public void DecodeBitArray_rejects_an_empty_bitmap_with_InvalidDataException()
public async Task DecodeBitArray_rejects_an_empty_bitmap_with_InvalidDataException()
{
var decode = typeof(ModbusDriver).GetMethod(
"DecodeBitArray", BindingFlags.NonPublic | BindingFlags.Static)!;
@@ -542,11 +543,49 @@ public sealed class ModbusLifecycleHygieneTests
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("Coil", ModbusRegion.Coils, 0, ModbusDataType.Bool)]),
};
var drv = new ModbusDriver(opts, "modbus-1", _ => fake);
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
await FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
var r = drv.ReadAsync(["Coil"], CancellationToken.None).GetAwaiter().GetResult();
// The empty-bitmap guard surfaces via the BadCommunicationError catch-all.
r[0].StatusCode.ShouldBe(0x80050000u);
var r = await drv.ReadAsync(["Coil"], CancellationToken.None);
// The empty-bitmap guard surfaces via the BadCommunicationError catch-all.
r[0].StatusCode.ShouldBe(0x80050000u);
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"Modbus empty bitmap cleanup failed: {failure}"));
}
[Fact]
public async Task Body_failure_after_probe_request_cancels_it_and_disposes_transport()
{
var fake = new EmptyBitTransport();
var opts = new ModbusDriverOptions
{
Host = "fake",
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("Coil", ModbusRegion.Coils, 0, ModbusDataType.Bool)]),
};
var drv = new ModbusDriver(opts, "modbus-1", _ => fake);
var sentinel = new InvalidOperationException("body sentinel");
try
{
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
var result = await drv.ReadAsync(["Coil"], CancellationToken.None);
result[0].StatusCode.ShouldBe(0x80050000u);
fake.CoilRequestCount.ShouldBe(1);
await fake.ProbeEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
fake.ProbeToken.IsCancellationRequested.ShouldBeFalse();
fake.DisposeCount.ShouldBe(0);
throw sentinel;
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"Modbus probe fixture cleanup failed: {failure}")));
actual.ShouldBeSameAs(sentinel);
fake.ProbeToken.IsCancellationRequested.ShouldBeTrue();
await fake.ProbeCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
await fake.ProbeRequestCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
fake.DisposeCount.ShouldBe(1);
}
finally { fake.ReleaseProbe.TrySetResult(); }
}
/// <summary>
@@ -556,6 +595,14 @@ public sealed class ModbusLifecycleHygieneTests
/// </summary>
private sealed class EmptyBitTransport : IModbusTransport
{
public TaskCompletionSource ProbeEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource ProbeCancelled { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource ProbeRequestCompleted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource ReleaseProbe { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public CancellationToken ProbeToken { get; private set; }
public int DisposeCount { get; private set; }
public int CoilRequestCount { get; private set; }
/// <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;
@@ -563,10 +610,29 @@ public sealed class ModbusLifecycleHygieneTests
/// <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;
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (pdu[0] == 0x03)
{
ProbeToken = ct;
ProbeEntered.TrySetResult();
try { await ReleaseProbe.Task.WaitAsync(ct); }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
ProbeCancelled.TrySetResult();
throw;
}
finally { ProbeRequestCompleted.TrySetResult(); }
}
if (pdu[0] == 0x01) CoilRequestCount++;
return new byte[] { pdu[0], 0 };
}
/// <summary>Records transport disposal.</summary>
public ValueTask DisposeAsync()
{
DisposeCount++;
return ValueTask.CompletedTask;
}
}
// -------------------- Finding -003 (volatile _health) --------------------