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:
+85
-18
@@ -1,4 +1,5 @@
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Tests;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
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.
|
/// orphaned the previous state's probe loop + handles. It now releases them first.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[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>
|
/// <summary>Initialize twice: the first state's probe loop has stopped and its probe handle is disposed.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -27,28 +28,94 @@ public sealed class AbCipReinitializeReleasesStateTests
|
|||||||
ProbeTagPath = "@raw_cpu_type",
|
ProbeTagPath = "@raw_cpu_type",
|
||||||
},
|
},
|
||||||
}, "drv-reinit", factory);
|
}, "drv-reinit", factory);
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await FixtureCleanup.RunAsync(async () =>
|
||||||
|
{
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
var first = drv.GetDeviceState(host)!;
|
var first = drv.GetDeviceState(host)!;
|
||||||
// Positive control: the first probe loop is running and has initialised its runtime.
|
// Positive control: the first probe loop is running and has initialised its runtime.
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
while (!(factory.Tags.TryGetValue("@raw_cpu_type", out var t) && t.InitializeCount > 0))
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
|
||||||
|
await Task.Delay(10);
|
||||||
|
}
|
||||||
|
var firstTag = factory.Tags["@raw_cpu_type"];
|
||||||
|
var firstLoop = first.ProbeTask;
|
||||||
|
_ = firstLoop.ShouldNotBeNull();
|
||||||
|
firstLoop!.IsCompleted.ShouldBeFalse();
|
||||||
|
firstTag.Disposed.ShouldBeFalse();
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
firstLoop.IsCompleted.ShouldBeTrue(); // joined
|
||||||
|
firstTag.Disposed.ShouldBeTrue();
|
||||||
|
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
|
||||||
|
}, () => drv.ShutdownAsync(CancellationToken.None),
|
||||||
|
failure => output.WriteLine($"AbCip 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 = "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);
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
while (!(factory.Tags.TryGetValue("@raw_cpu_type", out var t) && t.InitializeCount > 0))
|
while (!condition())
|
||||||
{
|
{
|
||||||
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
|
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
|
||||||
await Task.Delay(10);
|
await Task.Delay(10);
|
||||||
}
|
}
|
||||||
var firstTag = factory.Tags["@raw_cpu_type"];
|
|
||||||
var firstLoop = first.ProbeTask;
|
|
||||||
firstLoop.ShouldNotBeNull();
|
|
||||||
firstLoop!.IsCompleted.ShouldBeFalse();
|
|
||||||
firstTag.Disposed.ShouldBeFalse();
|
|
||||||
|
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
||||||
|
|
||||||
firstLoop.IsCompleted.ShouldBeTrue(); // joined
|
|
||||||
firstTag.Disposed.ShouldBeTrue();
|
|
||||||
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
|
|
||||||
|
|
||||||
await drv.ShutdownAsync(CancellationToken.None);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeCipTarget.cs" Link="Fakes\FakeCipTarget.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+79
-18
@@ -1,4 +1,5 @@
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Tests;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
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.
|
/// probe loop + runtime. It now releases them first.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[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>
|
/// <summary>Initialize twice: the first state's probe loop has stopped and its probe runtime is disposed.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -23,28 +24,88 @@ public sealed class AbLegacyReinitializeReleasesStateTests
|
|||||||
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
|
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
|
||||||
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), ProbeAddress = "S:0" },
|
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), ProbeAddress = "S:0" },
|
||||||
}, "drv-reinit", factory);
|
}, "drv-reinit", factory);
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await FixtureCleanup.RunAsync(async () =>
|
||||||
|
{
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
var first = drv.GetDeviceState(host)!;
|
var first = drv.GetDeviceState(host)!;
|
||||||
// Positive control: the first state's probe loop is running and has created + initialised its runtime.
|
// Positive control: the first state's probe loop is running and has created + initialised its runtime.
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
while (!(factory.Tags.TryGetValue("S:0", out var t) && t.InitializeCount > 0))
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
|
||||||
|
await Task.Delay(10);
|
||||||
|
}
|
||||||
|
var firstTag = factory.Tags["S:0"];
|
||||||
|
var firstLoop = first.ProbeTask;
|
||||||
|
_ = firstLoop.ShouldNotBeNull();
|
||||||
|
firstLoop!.IsCompleted.ShouldBeFalse();
|
||||||
|
firstTag.Disposed.ShouldBeFalse();
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
firstLoop.IsCompleted.ShouldBeTrue(); // joined
|
||||||
|
firstTag.Disposed.ShouldBeTrue();
|
||||||
|
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
|
||||||
|
}, () => drv.ShutdownAsync(CancellationToken.None),
|
||||||
|
failure => output.WriteLine($"AbLegacy 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 = "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);
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
while (!(factory.Tags.TryGetValue("S:0", out var t) && t.InitializeCount > 0))
|
while (!condition())
|
||||||
{
|
{
|
||||||
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
|
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
|
||||||
await Task.Delay(10);
|
await Task.Delay(10);
|
||||||
}
|
}
|
||||||
var firstTag = factory.Tags["S:0"];
|
|
||||||
var firstLoop = first.ProbeTask;
|
|
||||||
firstLoop.ShouldNotBeNull();
|
|
||||||
firstLoop!.IsCompleted.ShouldBeFalse();
|
|
||||||
firstTag.Disposed.ShouldBeFalse();
|
|
||||||
|
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
||||||
|
|
||||||
firstLoop.IsCompleted.ShouldBeTrue(); // joined
|
|
||||||
firstTag.Disposed.ShouldBeTrue();
|
|
||||||
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
|
|
||||||
|
|
||||||
await drv.ShutdownAsync(CancellationToken.None);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -38,4 +38,7 @@
|
|||||||
<None Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Captures\*.hex" Link="Captures\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest"/>
|
<None Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Captures\*.hex" Link="Captures\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+70
-16
@@ -1,4 +1,5 @@
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Tests;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
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.
|
/// probe loop + client. It now releases them first.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[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>
|
/// <summary>Initialize twice: the first state's probe loop has finished and its client is disposed.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -25,25 +26,78 @@ public sealed class FocasReinitializeReleasesStateTests
|
|||||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(2) },
|
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(2) },
|
||||||
};
|
};
|
||||||
var drv = new FocasDriver(options, "focas-reinit", factory);
|
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)!;
|
var first = drv.GetDeviceState(host)!;
|
||||||
// Positive control: the first state's probe loop is demonstrably running and holds a live client.
|
// 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);
|
await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
|
||||||
var firstLoop = first.ProbeTask;
|
var firstLoop = first.ProbeTask;
|
||||||
firstLoop.ShouldNotBeNull();
|
_ = firstLoop.ShouldNotBeNull();
|
||||||
firstLoop!.IsCompleted.ShouldBeFalse();
|
firstLoop!.IsCompleted.ShouldBeFalse();
|
||||||
var firstClient = factory.Clients[0];
|
var firstClient = factory.Clients[0];
|
||||||
|
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
firstLoop.IsCompleted.ShouldBeTrue(); // joined
|
firstLoop.IsCompleted.ShouldBeTrue(); // joined
|
||||||
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
|
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();
|
firstClient.IsConnected.ShouldBeFalse();
|
||||||
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
|
if (afterReplacement)
|
||||||
drv.GetDeviceState(host)!.ProbeTask.ShouldNotBeNull(); // the new state is running
|
{
|
||||||
|
secondLoop!.IsCompleted.ShouldBeTrue();
|
||||||
await drv.ShutdownAsync(CancellationToken.None);
|
secondClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||||
|
secondClient.IsConnected.ShouldBeFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WaitUntilAsync(Func<bool> cond)
|
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"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+71
-9
@@ -1,4 +1,5 @@
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Tests;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
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
|
/// 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.
|
/// defect the happy-path assertions alone would pass straight over.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class MTConnectDriverLifecycleTests
|
public sealed class MTConnectDriverLifecycleTests(ITestOutputHelper output)
|
||||||
{
|
{
|
||||||
private const string AgentUri = "http://fixture-agent:5000";
|
private const string AgentUri = "http://fixture-agent:5000";
|
||||||
|
|
||||||
@@ -598,18 +599,79 @@ public sealed class MTConnectDriverLifecycleTests
|
|||||||
// same "browse must not be left permanently empty" obligation.
|
// same "browse must not be left permanently empty" obligation.
|
||||||
|
|
||||||
[Fact]
|
[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();
|
driver.InvalidateProbeModel();
|
||||||
|
|
||||||
driver.CachedProbeModel.ShouldBeNull();
|
driver.CachedProbeModel.ShouldBeNull();
|
||||||
|
|
||||||
// The index is required for correctness (it IS the read surface) — never flushable.
|
// The index is required for correctness (it IS the read surface) — never flushable.
|
||||||
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
|
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
|
||||||
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
|
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
|
||||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
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]
|
[Fact]
|
||||||
|
|||||||
+3
@@ -39,4 +39,7 @@
|
|||||||
</Content>
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+76
-10
@@ -1,6 +1,7 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Tests;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
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.
|
/// unit test alongside them per Driver.Modbus-012.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
public sealed class ModbusLifecycleHygieneTests
|
public sealed class ModbusLifecycleHygieneTests(ITestOutputHelper output)
|
||||||
{
|
{
|
||||||
private sealed class FakeTransport : IModbusTransport
|
private sealed class FakeTransport : IModbusTransport
|
||||||
{
|
{
|
||||||
@@ -528,7 +529,7 @@ public sealed class ModbusLifecycleHygieneTests
|
|||||||
|
|
||||||
/// <summary>Verifies that DecodeBitArray rejects an empty bitmap with InvalidDataException.</summary>
|
/// <summary>Verifies that DecodeBitArray rejects an empty bitmap with InvalidDataException.</summary>
|
||||||
[Fact]
|
[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(
|
var decode = typeof(ModbusDriver).GetMethod(
|
||||||
"DecodeBitArray", BindingFlags.NonPublic | BindingFlags.Static)!;
|
"DecodeBitArray", BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||||
@@ -542,11 +543,49 @@ public sealed class ModbusLifecycleHygieneTests
|
|||||||
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("Coil", ModbusRegion.Coils, 0, ModbusDataType.Bool)]),
|
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("Coil", ModbusRegion.Coils, 0, ModbusDataType.Bool)]),
|
||||||
};
|
};
|
||||||
var drv = new ModbusDriver(opts, "modbus-1", _ => fake);
|
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.
|
// The empty-bitmap guard surfaces via the BadCommunicationError catch-all.
|
||||||
r[0].StatusCode.ShouldBe(0x80050000u);
|
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>
|
/// <summary>
|
||||||
@@ -556,6 +595,14 @@ public sealed class ModbusLifecycleHygieneTests
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class EmptyBitTransport : IModbusTransport
|
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>
|
/// <summary>Returns a completed task without performing any connection.</summary>
|
||||||
/// <param name="ct">The cancellation token for the operation.</param>
|
/// <param name="ct">The cancellation token for the operation.</param>
|
||||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
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="unitId">The Modbus unit ID.</param>
|
||||||
/// <param name="pdu">The protocol data unit being sent.</param>
|
/// <param name="pdu">The protocol data unit being sent.</param>
|
||||||
/// <param name="ct">The cancellation token for the operation.</param>
|
/// <param name="ct">The cancellation token for the operation.</param>
|
||||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
public async 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>
|
if (pdu[0] == 0x03)
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
{
|
||||||
|
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) --------------------
|
// -------------------- 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"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+79
-18
@@ -1,4 +1,5 @@
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Tests;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
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.
|
/// orphaned the previous state's probe loop + client. It now releases them first.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[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>
|
/// <summary>Initialize twice: the first state's probe loop has finished and its client is disposed.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -21,28 +22,88 @@ public sealed class TwinCATReinitializeReleasesStateTests
|
|||||||
Devices = [new TwinCATDeviceOptions(host)],
|
Devices = [new TwinCATDeviceOptions(host)],
|
||||||
Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(1) },
|
Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(1) },
|
||||||
}, "drv-reinit", factory);
|
}, "drv-reinit", factory);
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await FixtureCleanup.RunAsync(async () =>
|
||||||
|
{
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
var first = drv.GetDeviceState(host)!;
|
var first = drv.GetDeviceState(host)!;
|
||||||
// Positive control: the first probe loop is running and holds a connected client.
|
// Positive control: the first probe loop is running and holds a connected client.
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
while (!(factory.Clients.Count > 0 && factory.Clients[0].IsConnected))
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never connected");
|
||||||
|
await Task.Delay(10);
|
||||||
|
}
|
||||||
|
var firstClient = factory.Clients[0];
|
||||||
|
var firstLoop = first.ProbeTask;
|
||||||
|
_ = firstLoop.ShouldNotBeNull();
|
||||||
|
firstLoop!.IsCompleted.ShouldBeFalse();
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
firstLoop.IsCompleted.ShouldBeTrue(); // joined
|
||||||
|
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||||
|
firstClient.IsConnected.ShouldBeFalse();
|
||||||
|
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
|
||||||
|
}, () => drv.ShutdownAsync(CancellationToken.None),
|
||||||
|
failure => output.WriteLine($"TwinCAT 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 = "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);
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
while (!(factory.Clients.Count > 0 && factory.Clients[0].IsConnected))
|
while (!condition())
|
||||||
{
|
{
|
||||||
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never connected");
|
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never connected");
|
||||||
await Task.Delay(10);
|
await Task.Delay(10);
|
||||||
}
|
}
|
||||||
var firstClient = factory.Clients[0];
|
|
||||||
var firstLoop = first.ProbeTask;
|
|
||||||
firstLoop.ShouldNotBeNull();
|
|
||||||
firstLoop!.IsCompleted.ShouldBeFalse();
|
|
||||||
|
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
||||||
|
|
||||||
firstLoop.IsCompleted.ShouldBeTrue(); // joined
|
|
||||||
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
|
|
||||||
firstClient.IsConnected.ShouldBeFalse();
|
|
||||||
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
|
|
||||||
|
|
||||||
await drv.ShutdownAsync(CancellationToken.None);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -29,4 +29,7 @@
|
|||||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</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