using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// /// Driver.AbLegacy-011 — synchronous must perform real /// synchronous teardown rather than blocking via DisposeAsync().AsTask().GetAwaiter().GetResult(). /// Driver.AbLegacy-013 — fallback when the reference /// is unknown and no devices are configured is the documented single-host fallback per the /// IPerCallHostResolver contract. /// [Trait("Category", "Unit")] public sealed class AbLegacyDisposeAndResolveHostTests { // ---- Driver.AbLegacy-011 ---- [Fact] public async Task Dispose_runs_teardown_without_blocking_on_async_wait() { // Build a driver with a real device + tag so InitializeAsync registers state, then Dispose. // The teardown must clear the device dictionary just like ShutdownAsync would, but without // round-tripping through AsTask().GetAwaiter().GetResult() (which would deadlock under a // single-threaded synchronization context). var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)], Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)], Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-dispose", factory); await drv.InitializeAsync("{}", CancellationToken.None); // Materialise one runtime so DisposeRuntimes has work to do. await drv.ReadAsync(["X"], CancellationToken.None); factory.Tags["N7:0"].Disposed.ShouldBeFalse(); drv.Dispose(); // The cached libplctag tag must be disposed and the device map cleared. factory.Tags["N7:0"].Disposed.ShouldBeTrue(); drv.DeviceCount.ShouldBe(0); drv.GetHealth().State.ShouldBe(DriverState.Unknown); } [Fact] public async Task Dispose_is_idempotent() { var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.Dispose(); Should.NotThrow(() => drv.Dispose()); } [Fact] public async Task Dispose_under_single_threaded_sync_context_does_not_deadlock() { // The legacy sync-over-async pattern (DisposeAsync().AsTask().GetAwaiter().GetResult()) // can deadlock if any awaited continuation marshals back to a captured single-threaded // context. Drive a single-threaded SynchronizationContext + Dispose() and ensure it // returns within a short timeout. var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)], Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["X"], CancellationToken.None); using var ctx = new SingleThreadSynchronizationContext(); var prior = SynchronizationContext.Current; SynchronizationContext.SetSynchronizationContext(ctx); try { var disposed = new ManualResetEventSlim(false); ctx.Post(_ => { drv.Dispose(); disposed.Set(); }, null); ctx.RunUntil(disposed); disposed.IsSet.ShouldBeTrue("Dispose must return without blocking on the single-threaded context"); } finally { SynchronizationContext.SetSynchronizationContext(prior); } } /// /// Minimal cooperative single-threaded SynchronizationContext for the deadlock-regression /// test. The thread that calls pumps queued callbacks until the /// stop event is set. /// private sealed class SingleThreadSynchronizationContext : SynchronizationContext, IDisposable { private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback, object?)> _queue = new(); public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state)); public override void Send(SendOrPostCallback d, object? state) => d(state); public void RunUntil(ManualResetEventSlim stop) { while (!stop.IsSet) { if (_queue.TryTake(out var item, TimeSpan.FromSeconds(2))) item.Item1(item.Item2); else throw new TimeoutException("Dispose did not complete — likely sync-over-async deadlock"); } } public void Dispose() => _queue.Dispose(); } // ---- Driver.AbLegacy-013 ---- [Fact] public void ResolveHost_known_reference_returns_tag_device() { var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)], }, "drv-1"); drv.ResolveHost("X").ShouldBe("ab://10.0.0.5/1,0"); } [Fact] public void ResolveHost_unknown_reference_with_devices_returns_first_device() { // Multi-device fallback: an unknown reference returns the first configured device so the // resilience pipeline keys on a real ab:// host rather than the instance id. var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [ new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"), new AbLegacyDeviceOptions("ab://10.0.0.6/"), ], }, "drv-1"); drv.ResolveHost("unknown").ShouldBe("ab://10.0.0.5/1,0"); } [Fact] public void ResolveHost_unknown_reference_no_devices_returns_driver_instance_id() { // Per IPerCallHostResolver: implementations MUST NOT throw on an unknown reference; they // must return the driver's default-host string. With no devices configured the driver // instance id is the documented single-host fallback. var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-singleton"); drv.ResolveHost("anything").ShouldBe("drv-singleton"); } }