Merge pull request 'Ensure driver test cleanup preserves primary failures' (#730) from test/task-assertion-return-discard into master
v2-ci / unit-tests (push) Successful in 1h1m21s
v2-ci / unit-tests (push) Successful in 1h1m21s
This commit was merged in pull request #730.
This commit is contained in:
+70
-3
@@ -1,4 +1,5 @@
|
||||
using Shouldly;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
@@ -8,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
/// orphaned the previous state's probe loop + handles. It now releases them first.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipReinitializeReleasesStateTests
|
||||
public sealed class AbCipReinitializeReleasesStateTests(ITestOutputHelper output)
|
||||
{
|
||||
/// <summary>Initialize twice: the first state's probe loop has stopped and its probe handle is disposed.</summary>
|
||||
[Fact]
|
||||
@@ -27,6 +28,8 @@ public sealed class AbCipReinitializeReleasesStateTests
|
||||
ProbeTagPath = "@raw_cpu_type",
|
||||
},
|
||||
}, "drv-reinit", factory);
|
||||
await FixtureCleanup.RunAsync(async () =>
|
||||
{
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var first = drv.GetDeviceState(host)!;
|
||||
@@ -39,7 +42,7 @@ public sealed class AbCipReinitializeReleasesStateTests
|
||||
}
|
||||
var firstTag = factory.Tags["@raw_cpu_type"];
|
||||
var firstLoop = first.ProbeTask;
|
||||
firstLoop.ShouldNotBeNull();
|
||||
_ = firstLoop.ShouldNotBeNull();
|
||||
firstLoop!.IsCompleted.ShouldBeFalse();
|
||||
firstTag.Disposed.ShouldBeFalse();
|
||||
|
||||
@@ -48,7 +51,71 @@ public sealed class AbCipReinitializeReleasesStateTests
|
||||
firstLoop.IsCompleted.ShouldBeTrue(); // joined
|
||||
firstTag.Disposed.ShouldBeTrue();
|
||||
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
|
||||
}, () => drv.ShutdownAsync(CancellationToken.None),
|
||||
failure => output.WriteLine($"AbCip SecondInitialize cleanup failed: {failure}"));
|
||||
}
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
/// <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 = "ab://10.0.0.5/1,0";
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(host)],
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(20),
|
||||
Timeout = TimeSpan.FromMilliseconds(200),
|
||||
ProbeTagPath = "@raw_cpu_type",
|
||||
},
|
||||
}, "drv-reinit", factory);
|
||||
Task? firstLoop = null, secondLoop = null;
|
||||
FakeAbCipTag? firstTag = null, secondTag = null;
|
||||
var sentinel = new InvalidOperationException("body sentinel");
|
||||
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(async () =>
|
||||
{
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForProbeAsync(() => factory.Tags.TryGetValue("@raw_cpu_type", out var tag) && tag.InitializeCount > 0);
|
||||
firstTag = factory.Tags["@raw_cpu_type"];
|
||||
firstLoop = drv.GetDeviceState(host)!.ProbeTask;
|
||||
_ = firstLoop.ShouldNotBeNull();
|
||||
firstLoop!.IsCompleted.ShouldBeFalse();
|
||||
firstTag.Disposed.ShouldBeFalse();
|
||||
if (afterReplacement)
|
||||
{
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForProbeAsync(() => factory.Tags.TryGetValue("@raw_cpu_type", out var tag) && !ReferenceEquals(tag, firstTag) && tag.InitializeCount > 0);
|
||||
secondTag = factory.Tags["@raw_cpu_type"];
|
||||
secondLoop = drv.GetDeviceState(host)!.ProbeTask;
|
||||
_ = secondLoop.ShouldNotBeNull();
|
||||
secondLoop!.IsCompleted.ShouldBeFalse();
|
||||
}
|
||||
throw sentinel;
|
||||
}, () => drv.ShutdownAsync(CancellationToken.None),
|
||||
failure => output.WriteLine($"AbCip failure fixture cleanup failed: {failure}")));
|
||||
actual.ShouldBeSameAs(sentinel);
|
||||
firstLoop!.IsCompleted.ShouldBeTrue();
|
||||
firstTag!.Disposed.ShouldBeTrue();
|
||||
if (afterReplacement)
|
||||
{
|
||||
secondLoop!.IsCompleted.ShouldBeTrue();
|
||||
secondTag!.Disposed.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForProbeAsync(Func<bool> condition)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FixtureCleanupTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Successful_body_runs_one_awaited_cleanup()
|
||||
{
|
||||
var calls = 0;
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var run = FixtureCleanup.RunAsync(() => Task.CompletedTask, async () =>
|
||||
{
|
||||
calls++;
|
||||
await release.Task;
|
||||
}, _ => throw new Exception("unexpected report"));
|
||||
|
||||
run.IsCompleted.ShouldBeFalse();
|
||||
release.SetResult();
|
||||
await run;
|
||||
calls.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Body_fault_remains_same_exception_and_original_throwing_stack()
|
||||
{
|
||||
var primary = new InvalidOperationException("body");
|
||||
var cleanupCalls = 0;
|
||||
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(
|
||||
() => ThrowFromBody(primary),
|
||||
() => { cleanupCalls++; return Task.CompletedTask; },
|
||||
_ => throw new Exception("unexpected report")));
|
||||
|
||||
actual.ShouldBeSameAs(primary);
|
||||
actual.StackTrace.ShouldNotBeNull().ShouldContain(nameof(ThrowFromBody));
|
||||
cleanupCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cleanup_only_fault_is_thrown_exactly()
|
||||
{
|
||||
var cleanup = new ApplicationException("cleanup");
|
||||
var actual = await Should.ThrowAsync<ApplicationException>(() => FixtureCleanup.RunAsync(
|
||||
() => Task.CompletedTask,
|
||||
() => Task.FromException(cleanup),
|
||||
_ => throw new Exception("unexpected report")));
|
||||
actual.ShouldBeSameAs(cleanup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Both_faults_preserve_primary_and_record_secondary_once()
|
||||
{
|
||||
var primary = new InvalidOperationException("body");
|
||||
var cleanup = new ApplicationException("cleanup");
|
||||
var reported = new List<Exception>();
|
||||
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(
|
||||
() => ThrowFromBody(primary),
|
||||
() => Task.FromException(cleanup),
|
||||
reported.Add));
|
||||
|
||||
actual.ShouldBeSameAs(primary);
|
||||
actual.StackTrace.ShouldNotBeNull().ShouldContain(nameof(ThrowFromBody));
|
||||
actual.Data[FixtureCleanup.SecondaryExceptionKey].ShouldBeSameAs(cleanup);
|
||||
reported.Count.ShouldBe(1);
|
||||
reported[0].ShouldBeSameAs(cleanup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reporting_fault_does_not_replace_primary()
|
||||
{
|
||||
var primary = new InvalidOperationException("body");
|
||||
var cleanup = new ApplicationException("cleanup");
|
||||
var reportCalls = 0;
|
||||
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(
|
||||
() => ThrowFromBody(primary),
|
||||
() => Task.FromException(cleanup),
|
||||
_ => { reportCalls++; throw new Exception("report"); }));
|
||||
|
||||
actual.ShouldBeSameAs(primary);
|
||||
actual.Data[FixtureCleanup.SecondaryExceptionKey].ShouldBeSameAs(cleanup);
|
||||
reportCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
private static async Task ThrowFromBody(Exception failure)
|
||||
{
|
||||
await Task.Yield();
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
+3
@@ -38,4 +38,7 @@
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeCipTarget.cs" Link="Fakes\FakeCipTarget.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+64
-3
@@ -1,4 +1,5 @@
|
||||
using Shouldly;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
@@ -10,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
/// probe loop + runtime. It now releases them first.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyReinitializeReleasesStateTests
|
||||
public sealed class AbLegacyReinitializeReleasesStateTests(ITestOutputHelper output)
|
||||
{
|
||||
/// <summary>Initialize twice: the first state's probe loop has stopped and its probe runtime is disposed.</summary>
|
||||
[Fact]
|
||||
@@ -23,6 +24,8 @@ public sealed class AbLegacyReinitializeReleasesStateTests
|
||||
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), ProbeAddress = "S:0" },
|
||||
}, "drv-reinit", factory);
|
||||
await FixtureCleanup.RunAsync(async () =>
|
||||
{
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var first = drv.GetDeviceState(host)!;
|
||||
@@ -35,7 +38,7 @@ public sealed class AbLegacyReinitializeReleasesStateTests
|
||||
}
|
||||
var firstTag = factory.Tags["S:0"];
|
||||
var firstLoop = first.ProbeTask;
|
||||
firstLoop.ShouldNotBeNull();
|
||||
_ = firstLoop.ShouldNotBeNull();
|
||||
firstLoop!.IsCompleted.ShouldBeFalse();
|
||||
firstTag.Disposed.ShouldBeFalse();
|
||||
|
||||
@@ -44,7 +47,65 @@ public sealed class AbLegacyReinitializeReleasesStateTests
|
||||
firstLoop.IsCompleted.ShouldBeTrue(); // joined
|
||||
firstTag.Disposed.ShouldBeTrue();
|
||||
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
|
||||
}, () => drv.ShutdownAsync(CancellationToken.None),
|
||||
failure => output.WriteLine($"AbLegacy SecondInitialize cleanup failed: {failure}"));
|
||||
}
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
/// <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 = "ab://10.0.0.5/1,0";
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), ProbeAddress = "S:0" },
|
||||
}, "drv-reinit", factory);
|
||||
Task? firstLoop = null, secondLoop = null;
|
||||
FakeAbLegacyTag? firstTag = null, secondTag = null;
|
||||
var sentinel = new InvalidOperationException("body sentinel");
|
||||
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(async () =>
|
||||
{
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForProbeAsync(() => factory.Tags.TryGetValue("S:0", out var tag) && tag.InitializeCount > 0);
|
||||
firstTag = factory.Tags["S:0"];
|
||||
firstLoop = drv.GetDeviceState(host)!.ProbeTask;
|
||||
_ = firstLoop.ShouldNotBeNull();
|
||||
firstLoop!.IsCompleted.ShouldBeFalse();
|
||||
firstTag.Disposed.ShouldBeFalse();
|
||||
if (afterReplacement)
|
||||
{
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForProbeAsync(() => factory.Tags.TryGetValue("S:0", out var tag) && !ReferenceEquals(tag, firstTag) && tag.InitializeCount > 0);
|
||||
secondTag = factory.Tags["S:0"];
|
||||
secondLoop = drv.GetDeviceState(host)!.ProbeTask;
|
||||
_ = secondLoop.ShouldNotBeNull();
|
||||
secondLoop!.IsCompleted.ShouldBeFalse();
|
||||
}
|
||||
throw sentinel;
|
||||
}, () => drv.ShutdownAsync(CancellationToken.None),
|
||||
failure => output.WriteLine($"AbLegacy failure fixture cleanup failed: {failure}")));
|
||||
actual.ShouldBeSameAs(sentinel);
|
||||
firstLoop!.IsCompleted.ShouldBeTrue();
|
||||
firstTag!.Disposed.ShouldBeTrue();
|
||||
if (afterReplacement)
|
||||
{
|
||||
secondLoop!.IsCompleted.ShouldBeTrue();
|
||||
secondTag!.Disposed.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForProbeAsync(Func<bool> condition)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+3
@@ -38,4 +38,7 @@
|
||||
<None Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Captures\*.hex" Link="Captures\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+58
-4
@@ -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,13 +26,15 @@ 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 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.ShouldNotBeNull();
|
||||
firstLoop!.IsCompleted.ShouldBeFalse();
|
||||
var firstClient = factory.Clients[0];
|
||||
|
||||
@@ -41,9 +44,60 @@ public sealed class FocasReinitializeReleasesStateTests
|
||||
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
firstClient.IsConnected.ShouldBeFalse();
|
||||
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
|
||||
drv.GetDeviceState(host)!.ProbeTask.ShouldNotBeNull(); // the new state is running
|
||||
_ = drv.GetDeviceState(host)!.ProbeTask.ShouldNotBeNull(); // the new state is running
|
||||
}, () => drv.ShutdownAsync(CancellationToken.None),
|
||||
failure => output.WriteLine($"FOCAS SecondInitialize cleanup failed: {failure}"));
|
||||
}
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
/// <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();
|
||||
if (afterReplacement)
|
||||
{
|
||||
secondLoop!.IsCompleted.ShouldBeTrue();
|
||||
secondClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
secondClient.IsConnected.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> cond)
|
||||
|
||||
+3
@@ -34,4 +34,7 @@
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+65
-3
@@ -1,4 +1,5 @@
|
||||
using Shouldly;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
@@ -16,7 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
/// keeps talking to the old Agent, and a cache flush that wedges browse. Each of those is a
|
||||
/// defect the happy-path assertions alone would pass straight over.
|
||||
/// </remarks>
|
||||
public sealed class MTConnectDriverLifecycleTests
|
||||
public sealed class MTConnectDriverLifecycleTests(ITestOutputHelper output)
|
||||
{
|
||||
private const string AgentUri = "http://fixture-agent:5000";
|
||||
|
||||
@@ -598,9 +599,12 @@ public sealed class MTConnectDriverLifecycleTests
|
||||
// same "browse must not be left permanently empty" obligation.
|
||||
|
||||
[Fact]
|
||||
public void Dropping_the_probe_cache_keeps_the_observation_index()
|
||||
public async Task Dropping_the_probe_cache_keeps_the_observation_index()
|
||||
{
|
||||
var (driver, _) = InitializedDriverAsync().GetAwaiter().GetResult();
|
||||
var (driver, _) = NewDriver();
|
||||
await FixtureCleanup.RunAsync(async () =>
|
||||
{
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
driver.InvalidateProbeModel();
|
||||
|
||||
@@ -610,6 +614,64 @@ public sealed class MTConnectDriverLifecycleTests
|
||||
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
|
||||
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}, () => driver.ShutdownAsync(CancellationToken.None),
|
||||
failure => output.WriteLine($"MTConnect cache drop cleanup failed: {failure}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Body_failure_after_probe_scheduler_enters_stops_probe_and_disposes_client()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var released = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var driver = new MTConnectDriver(Opts(), "mt1", _ => client,
|
||||
probeScheduler: async (_, ct) =>
|
||||
{
|
||||
entered.TrySetResult();
|
||||
try { await released.Task.WaitAsync(ct); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
cancelled.TrySetResult();
|
||||
throw;
|
||||
}
|
||||
});
|
||||
Task? probe = null;
|
||||
var sentinel = new InvalidOperationException("body sentinel");
|
||||
try
|
||||
{
|
||||
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(async () =>
|
||||
{
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
probe = driver.ProbeLoopTask;
|
||||
_ = probe.ShouldNotBeNull();
|
||||
probe!.IsCompleted.ShouldBeFalse();
|
||||
client.IsDisposed.ShouldBeFalse();
|
||||
throw sentinel;
|
||||
}, () => driver.ShutdownAsync(CancellationToken.None),
|
||||
failure => output.WriteLine($"MTConnect probe fixture cleanup failed: {failure}")));
|
||||
actual.ShouldBeSameAs(sentinel);
|
||||
await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
probe!.IsCompleted.ShouldBeTrue();
|
||||
client.DisposeCount.ShouldBe(1);
|
||||
}
|
||||
finally { released.TrySetResult(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialization_failure_disposes_the_client_inside_fixture_boundary()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var failure = new HttpRequestException("current unavailable");
|
||||
client.CurrentFailure = failure;
|
||||
var driver = new MTConnectDriver(Opts(), "mt1", _ => client);
|
||||
var actual = await Should.ThrowAsync<HttpRequestException>(() => FixtureCleanup.RunAsync(
|
||||
() => driver.InitializeAsync("{}", CancellationToken.None),
|
||||
() => driver.ShutdownAsync(CancellationToken.None),
|
||||
secondary => output.WriteLine($"MTConnect init fixture cleanup failed: {secondary}")));
|
||||
actual.ShouldBeSameAs(failure);
|
||||
client.DisposeCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+3
@@ -39,4 +39,7 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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();
|
||||
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) --------------------
|
||||
|
||||
+3
@@ -29,4 +29,7 @@
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+64
-3
@@ -1,4 +1,5 @@
|
||||
using Shouldly;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
@@ -8,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
/// orphaned the previous state's probe loop + client. It now releases them first.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATReinitializeReleasesStateTests
|
||||
public sealed class TwinCATReinitializeReleasesStateTests(ITestOutputHelper output)
|
||||
{
|
||||
/// <summary>Initialize twice: the first state's probe loop has finished and its client is disposed.</summary>
|
||||
[Fact]
|
||||
@@ -21,6 +22,8 @@ public sealed class TwinCATReinitializeReleasesStateTests
|
||||
Devices = [new TwinCATDeviceOptions(host)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(1) },
|
||||
}, "drv-reinit", factory);
|
||||
await FixtureCleanup.RunAsync(async () =>
|
||||
{
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var first = drv.GetDeviceState(host)!;
|
||||
@@ -33,7 +36,7 @@ public sealed class TwinCATReinitializeReleasesStateTests
|
||||
}
|
||||
var firstClient = factory.Clients[0];
|
||||
var firstLoop = first.ProbeTask;
|
||||
firstLoop.ShouldNotBeNull();
|
||||
_ = firstLoop.ShouldNotBeNull();
|
||||
firstLoop!.IsCompleted.ShouldBeFalse();
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
@@ -42,7 +45,65 @@ public sealed class TwinCATReinitializeReleasesStateTests
|
||||
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
firstClient.IsConnected.ShouldBeFalse();
|
||||
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
|
||||
}, () => drv.ShutdownAsync(CancellationToken.None),
|
||||
failure => output.WriteLine($"TwinCAT SecondInitialize cleanup failed: {failure}"));
|
||||
}
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
/// <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 = "ads://5.23.91.23.1.1:851";
|
||||
var factory = new FakeTwinCATClientFactory();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(host)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(1) },
|
||||
}, "drv-reinit", factory);
|
||||
Task? firstLoop = null, secondLoop = null;
|
||||
FakeTwinCATClient? 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($"TwinCAT failure fixture cleanup failed: {failure}")));
|
||||
actual.ShouldBeSameAs(sentinel);
|
||||
firstLoop!.IsCompleted.ShouldBeTrue();
|
||||
firstClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
firstClient.IsConnected.ShouldBeFalse();
|
||||
if (afterReplacement)
|
||||
{
|
||||
secondLoop!.IsCompleted.ShouldBeTrue();
|
||||
secondClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
secondClient.IsConnected.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never connected");
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -29,4 +29,7 @@
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests;
|
||||
|
||||
internal static class FixtureCleanup
|
||||
{
|
||||
internal const string SecondaryExceptionKey = "FixtureCleanup.SecondaryException";
|
||||
|
||||
internal static async Task RunAsync(Func<Task> body, Func<Task> cleanup, Action<Exception> report)
|
||||
{
|
||||
Exception? primary = null;
|
||||
try
|
||||
{
|
||||
await body();
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
primary = failure;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await cleanup();
|
||||
}
|
||||
catch (Exception secondary) when (primary is not null)
|
||||
{
|
||||
try { primary.Data[SecondaryExceptionKey] = secondary; } catch { }
|
||||
try { report(secondary); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user