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,4 +1,5 @@
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
@@ -10,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// probe loop + client. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasReinitializeReleasesStateTests
public sealed class FocasReinitializeReleasesStateTests(ITestOutputHelper output)
{
/// <summary>Initialize twice: the first state's probe loop has finished and its client is disposed.</summary>
[Fact]
@@ -25,25 +26,78 @@ public sealed class FocasReinitializeReleasesStateTests
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(2) },
};
var drv = new FocasDriver(options, "focas-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first state's probe loop is demonstrably running and holds a live client.
await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
var firstLoop = first.ProbeTask;
_ = firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
var firstClient = factory.Clients[0];
var first = drv.GetDeviceState(host)!;
// Positive control: the first state's probe loop is demonstrably running and holds a live client.
await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
var firstLoop = first.ProbeTask;
_ = firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
var firstClient = factory.Clients[0];
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
_ = drv.GetDeviceState(host)!.ProbeTask.ShouldNotBeNull(); // the new state is running
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"FOCAS SecondInitialize cleanup failed: {failure}"));
}
/// <summary>Failure after a live first or replacement state must release both owned resources.</summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Body_failure_releases_live_probe_resources(bool afterReplacement)
{
const string host = "focas://10.0.0.5:8193";
var factory = new FakeFocasClientFactory();
var options = new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", host, "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(2) },
};
var drv = new FocasDriver(options, "focas-reinit", factory);
Task? firstLoop = null, secondLoop = null;
FakeFocasClient? firstClient = null, secondClient = null;
var sentinel = new InvalidOperationException("body sentinel");
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
firstClient = factory.Clients[0];
firstLoop = drv.GetDeviceState(host)!.ProbeTask;
_ = firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
if (afterReplacement)
{
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Clients.Count > 1 && factory.Clients[1].IsConnected);
secondClient = factory.Clients[1];
secondLoop = drv.GetDeviceState(host)!.ProbeTask;
_ = secondLoop.ShouldNotBeNull();
secondLoop!.IsCompleted.ShouldBeFalse();
}
throw sentinel;
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"FOCAS failure fixture cleanup failed: {failure}")));
actual.ShouldBeSameAs(sentinel);
firstLoop!.IsCompleted.ShouldBeTrue();
firstClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
_ = drv.GetDeviceState(host)!.ProbeTask.ShouldNotBeNull(); // the new state is running
await drv.ShutdownAsync(CancellationToken.None);
if (afterReplacement)
{
secondLoop!.IsCompleted.ShouldBeTrue();
secondClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
secondClient.IsConnected.ShouldBeFalse();
}
}
private static async Task WaitUntilAsync(Func<bool> cond)