using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; [Trait("Category", "Unit")] public sealed class FocasHandleRecycleTests { /// Verifies that the recycle loop disposes clients on interval and reopens fresh ones. [Fact] public async Task Recycle_loop_disposes_client_on_interval_reads_reopen_fresh_one() { var factory = new FakeFocasClientFactory(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, HandleRecycle = new FocasHandleRecycleOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(80), }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); // First read forces the initial connect. await drv.ReadAsync(["R"], CancellationToken.None); var initialClients = factory.Clients.Count; initialClients.ShouldBe(1); // Wait for a recycle tick, then read again — a new client must have been created. await WaitFor(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3)); await drv.ReadAsync(["R"], CancellationToken.None); factory.Clients.Count.ShouldBeGreaterThan(initialClients); await drv.ShutdownAsync(CancellationToken.None); } /// Verifies that the recycle loop stays off when not enabled. [Fact] public async Task Recycle_loop_stays_off_when_not_enabled() { var factory = new FakeFocasClientFactory(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["R"], CancellationToken.None); await Task.Delay(150); // With recycle off the same client stays live — no Dispose during the window. factory.Clients.Count.ShouldBe(1); factory.Clients[0].DisposeCount.ShouldBe(0); await drv.ShutdownAsync(CancellationToken.None); } /// A client whose reads take a while — long enough for a recycle tick to land mid-read. private sealed class SlowReadClient : FakeFocasClient { public override async Task<(object? value, uint status)> ReadAsync( FocasAddress address, FocasDataType type, CancellationToken ct) { await Task.Delay(200, ct).ConfigureAwait(false); if (!IsConnected) throw new ObjectDisposedException(nameof(SlowReadClient), "disposed mid-read"); return ((sbyte)5, FocasStatusMapper.Good); } } /// /// #740 / R07:L6 — the recycle loop disposed the device client with no gate, so a recycle tick landing /// mid-read killed that read (BadCommunicationError + Degraded, every interval). The recycle now takes the /// device's connect gate and lets the in-flight read finish before disposing the client it detached. /// [Fact] public async Task Recycle_tick_landing_mid_read_lets_the_read_finish() { var factory = new FakeFocasClientFactory { Customise = () => new SlowReadClient() }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, HandleRecycle = new FocasHandleRecycleOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); // Each 200 ms read spans at least three recycle ticks. for (var i = 0; i < 3; i++) { var snap = (await drv.ReadAsync(["R"], CancellationToken.None))[0]; snap.StatusCode.ShouldBe(FocasStatusMapper.Good, $"read {i} was killed by a recycle tick"); snap.Value.ShouldBe((sbyte)5); } drv.GetHealth().State.ShouldBe(Core.Abstractions.DriverState.Healthy); // Positive control: the recycle really ran — the first client was disposed and a fresh one opened. await WaitFor(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3)); factory.Clients[0].DisposeCount.ShouldBeGreaterThan(0); factory.Clients.Count.ShouldBeGreaterThan(1); await drv.ShutdownAsync(CancellationToken.None); factory.Clients.ShouldAllBe(c => !c.IsConnected, "no client opened around a recycle may be orphaned"); } /// DisposeWhenIdleAsync drains the in-flight op, then disposes; a later op is refused, not run on a dead socket. [Fact] public async Task DisposeWhenIdle_drains_the_in_flight_op_then_refuses_new_ones() { var inner = new SlowReadClient(); await inner.ConnectAsync(new FocasHostAddress("10.0.0.5", 8193), TimeSpan.FromSeconds(1), CancellationToken.None); var sync = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(5)); var addr = FocasAddress.TryParse("R100")!; var read = sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None); await Task.Delay(20); sync.PendingOperations.ShouldBe(1); var dispose = sync.DisposeWhenIdleAsync(TimeSpan.FromSeconds(5)); (await read).status.ShouldBe(FocasStatusMapper.Good); await dispose; inner.DisposeCount.ShouldBe(1); await Should.ThrowAsync(() => sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None)); } private static async Task WaitFor(Func pred, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (pred()) return; await Task.Delay(20); } } }