using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// #740 / R07:H13 — the device table is read by the retained poll engine, the fixed-tree loop, the alarm /// loop and the actor's health tick, so a re-initialise must build a fresh table and swap it in rather than /// clearing and refilling the live one. Also pins the lifecycle overlaps the runtime produces since Lane A: /// a dispose running beside a still-running shutdown, and an abandoned initialise overlapping the next /// attempt's shutdown. /// [Trait("Category", "Unit")] public sealed class FocasDeviceTableLifecycleTests { private const string Host = "focas://10.0.0.5:8193"; private static FocasDriverOptions Options(bool probe = false, bool fixedTree = false) => new() { Devices = [new FocasDeviceOptions(Host)], RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/R", Host, "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = probe, Interval = TimeSpan.FromMilliseconds(20) }, FixedTree = new FocasFixedTreeOptions { Enabled = fixedTree, PollInterval = TimeSpan.FromMilliseconds(10) }, }; /// A re-initialise publishes a NEW table; a reader holding the old one never sees it emptied. [Fact] public async Task Reinitialize_swaps_in_a_new_table_and_never_mutates_the_one_a_reader_holds() { var drv = new FocasDriver(Options(), "focas-swap", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); var before = drv.DeviceTable; before.Count.ShouldBe(1); await drv.InitializeAsync("{}", CancellationToken.None); drv.DeviceTable.ShouldNotBeSameAs(before); drv.DeviceTable.Count.ShouldBe(1); before.Count.ShouldBe(1, "the table a concurrent reader enumerates must not be cleared under it"); await drv.ShutdownAsync(CancellationToken.None); before.Count.ShouldBe(1); drv.DeviceTable.Count.ShouldBe(0); } /// Hammering every table reader during repeated re-initialises throws nothing — and reads still succeed. [Fact] public async Task Readers_survive_repeated_reinitialise() { var factory = new FakeFocasClientFactory(); var drv = new FocasDriver(Options(fixedTree: true), "focas-hammer", factory); await drv.InitializeAsync("{}", CancellationToken.None); using var stop = new CancellationTokenSource(); var good = 0; var errors = new List(); var reader = Task.Run(async () => { while (!stop.IsCancellationRequested) { try { _ = drv.GetHostStatuses(); var snaps = await drv.ReadAsync(["raw/R", $"{Host}/Identity/SeriesNumber"], CancellationToken.None); if (snaps[0].StatusCode == FocasStatusMapper.Good) Interlocked.Increment(ref good); } catch (Exception ex) { lock (errors) errors.Add(ex); } } }); for (var i = 0; i < 100; i++) await drv.InitializeAsync("{}", CancellationToken.None); await Task.Delay(100); stop.Cancel(); await reader; await drv.ShutdownAsync(CancellationToken.None); errors.ShouldBeEmpty(); good.ShouldBeGreaterThan(0, "positive control: the reader really did read through the table"); } /// A client whose connect parks until released and ignores cancellation. private sealed class ParkedConnectClient(TaskCompletionSource entered, Task release) : FakeFocasClient { public override async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) { entered.TrySetResult(); await release.ConfigureAwait(false); await base.ConnectAsync(address, timeout, CancellationToken.None).ConfigureAwait(false); } } /// /// A data-path read parked in connect when shutdown runs must not leave a live client on the released /// device: the device is retired, so the connect that completes afterwards disposes what it opened. /// [Fact] public async Task Connect_completing_after_shutdown_does_not_leave_a_live_client() { var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new FakeFocasClientFactory { Customise = () => new ParkedConnectClient(entered, release.Task) }; var drv = new FocasDriver(Options(), "focas-retire", factory); await drv.InitializeAsync("{}", CancellationToken.None); var read = drv.ReadAsync(["raw/R"], CancellationToken.None); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); var shutdown = drv.ShutdownAsync(CancellationToken.None); await Task.Delay(50); release.SetResult(); await shutdown.WaitAsync(TimeSpan.FromSeconds(10)); var snaps = await read.WaitAsync(TimeSpan.FromSeconds(5)); factory.Clients.Count.ShouldBe(1); factory.Clients[0].IsConnected.ShouldBeFalse("a connect finishing on a retired device must dispose its client"); factory.Clients[0].DisposeCount.ShouldBeGreaterThanOrEqualTo(1); snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); } /// /// An initialise abandoned mid-way (the runtime timed it out and moved on to a shutdown) must not /// resurrect the driver when it finally completes: no device table, no loops, no connection. /// [Fact] public async Task Abandoned_initialise_finishing_after_shutdown_starts_nothing() { var factory = new FakeFocasClientFactory(); var options = Options(probe: true); using var gate = new ManualResetEventSlim(false); var inRebind = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var drv = new FocasDriver(options, "focas-abandoned", factory, rebind: _ => { inRebind.TrySetResult(); gate.Wait(TimeSpan.FromSeconds(10)); return new FocasDriverBinding(options, factory); }); var init = Task.Run(() => drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None)); await inRebind.Task.WaitAsync(TimeSpan.FromSeconds(5)); await drv.ShutdownAsync(CancellationToken.None); gate.Set(); await Should.ThrowAsync(() => init.WaitAsync(TimeSpan.FromSeconds(10))); await Task.Delay(200); // several probe intervals — a started loop would have connected by now drv.DeviceCount.ShouldBe(0); factory.Clients.ShouldBeEmpty(); drv.GetHealth().State.ShouldNotBe(DriverState.Healthy); // Positive control: the same driver, initialised without being superseded, does start its probe. await drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None); await WaitUntil(() => factory.Clients.Count > 0); drv.DeviceCount.ShouldBe(1); drv.GetHealth().State.ShouldBe(DriverState.Healthy); await drv.DisposeAsync(); } /// A loop wedged in a read that ignores cancellation for a while. private sealed class SlowProbeClient : FakeFocasClient { public override async Task ProbeAsync(CancellationToken ct) { await Task.Delay(400, CancellationToken.None).ConfigureAwait(false); return true; } } /// /// Lane A disposes while its own shutdown may still be running. The dispose must not return before the /// teardown it overlaps has released the client — it waits for it rather than finding an empty table. /// [Fact] public async Task Dispose_concurrent_with_shutdown_waits_for_the_teardown() { var factory = new FakeFocasClientFactory { Customise = () => new SlowProbeClient() }; var drv = new FocasDriver(Options(probe: true), "focas-dispose", factory); await drv.InitializeAsync("{}", CancellationToken.None); await WaitUntil(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected); var shutdown = drv.ShutdownAsync(CancellationToken.None); var dispose = drv.DisposeAsync().AsTask(); await dispose.WaitAsync(TimeSpan.FromSeconds(10)); factory.Clients.ShouldAllBe(c => !c.IsConnected); factory.Clients.ShouldAllBe(c => c.DisposeCount >= 1); await shutdown.WaitAsync(TimeSpan.FromSeconds(10)); drv.DeviceCount.ShouldBe(0); } private static async Task WaitUntil(Func condition) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); while (!condition()) { if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached"); await Task.Delay(10); } } }