diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReinitializeReleasesStateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReinitializeReleasesStateTests.cs index ebee2056..b7891d27 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReinitializeReleasesStateTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReinitializeReleasesStateTests.cs @@ -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. /// [Trait("Category", "Unit")] -public sealed class AbCipReinitializeReleasesStateTests +public sealed class AbCipReinitializeReleasesStateTests(ITestOutputHelper output) { /// Initialize twice: the first state's probe loop has stopped and its probe handle is disposed. [Fact] @@ -27,28 +28,94 @@ public sealed class AbCipReinitializeReleasesStateTests ProbeTagPath = "@raw_cpu_type", }, }, "drv-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 probe loop is running and has initialised its runtime. + var first = drv.GetDeviceState(host)!; + // 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}")); + } + + /// Failure after a live first or replacement state must release both owned resources. + [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(() => 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 condition) + { 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"); 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); } + } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FixtureCleanupTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FixtureCleanupTests.cs new file mode 100644 index 00000000..582af8b7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FixtureCleanupTests.cs @@ -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(() => 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(() => 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(); + var actual = await Should.ThrowAsync(() => 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(() => 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; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj index 38c3d151..898e7284 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj @@ -38,4 +38,7 @@ + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs index 85f3d1b5..f2d6b0df 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs @@ -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. /// [Trait("Category", "Unit")] -public sealed class AbLegacyReinitializeReleasesStateTests +public sealed class AbLegacyReinitializeReleasesStateTests(ITestOutputHelper output) { /// Initialize twice: the first state's probe loop has stopped and its probe runtime is disposed. [Fact] @@ -23,28 +24,88 @@ 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 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 running and has created + initialised its runtime. + var first = drv.GetDeviceState(host)!; + // 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}")); + } + + /// Failure after a live first or replacement state must release both owned resources. + [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(() => 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 condition) + { 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"); 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); } + } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj index ae370b85..cc9bbc8c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj @@ -38,4 +38,7 @@ + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs index e1687105..2e3eb6b2 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs @@ -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. /// [Trait("Category", "Unit")] -public sealed class FocasReinitializeReleasesStateTests +public sealed class FocasReinitializeReleasesStateTests(ITestOutputHelper output) { /// Initialize twice: the first state's probe loop has finished and its client is disposed. [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}")); + } + + + /// Failure after a live first or replacement state must release both owned resources. + [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(() => 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 cond) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj index c4f4be54..d8351579 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj @@ -34,4 +34,7 @@ + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs index 857f606d..3e07df40 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs @@ -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. /// -public sealed class MTConnectDriverLifecycleTests +public sealed class MTConnectDriverLifecycleTests(ITestOutputHelper output) { 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. [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. - driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount); - driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u); - driver.GetHealth().State.ShouldBe(DriverState.Healthy); + // The index is required for correctness (it IS the read surface) — never flushable. + 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(() => 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(() => 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] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj index 866f7a9e..6b067f74 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj @@ -39,4 +39,7 @@ + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs index 316de319..35417060 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs @@ -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. /// [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 /// Verifies that DecodeBitArray rejects an empty bitmap with InvalidDataException. [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(() => 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(); } } /// @@ -556,6 +595,14 @@ public sealed class ModbusLifecycleHygieneTests /// 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; } + /// Returns a completed task without performing any connection. /// The cancellation token for the operation. public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; @@ -563,10 +610,29 @@ public sealed class ModbusLifecycleHygieneTests /// The Modbus unit ID. /// The protocol data unit being sent. /// The cancellation token for the operation. - public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) - => Task.FromResult(new byte[] { pdu[0], 0 }); - /// Completes the disposal without doing any work. - public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public async Task 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 }; + } + /// Records transport disposal. + public ValueTask DisposeAsync() + { + DisposeCount++; + return ValueTask.CompletedTask; + } } // -------------------- Finding -003 (volatile _health) -------------------- diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj index 980a367b..0dfbd27b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj @@ -29,4 +29,7 @@ + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReinitializeReleasesStateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReinitializeReleasesStateTests.cs index 8ef5f9eb..529f1d12 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReinitializeReleasesStateTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReinitializeReleasesStateTests.cs @@ -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. /// [Trait("Category", "Unit")] -public sealed class TwinCATReinitializeReleasesStateTests +public sealed class TwinCATReinitializeReleasesStateTests(ITestOutputHelper output) { /// Initialize twice: the first state's probe loop has finished and its client is disposed. [Fact] @@ -21,28 +22,88 @@ 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 drv.InitializeAsync("{}", CancellationToken.None); + await FixtureCleanup.RunAsync(async () => + { + await drv.InitializeAsync("{}", CancellationToken.None); - var first = drv.GetDeviceState(host)!; - // Positive control: the first probe loop is running and holds a connected client. + var first = drv.GetDeviceState(host)!; + // 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}")); + } + + /// Failure after a live first or replacement state must release both owned resources. + [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(() => 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 condition) + { 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"); 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); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj index fd7dacec..8b1dbba6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj @@ -29,4 +29,7 @@ + + + diff --git a/tests/TestSupport/FixtureCleanup.cs b/tests/TestSupport/FixtureCleanup.cs new file mode 100644 index 00000000..4cdaac85 --- /dev/null +++ b/tests/TestSupport/FixtureCleanup.cs @@ -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 body, Func cleanup, Action 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 { } + } + } + } +}