using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// #740 / R07:P9 — walked the whole batch sequentially, so a SLOW CNC /// (TCP-accept-then-stall, the live 31i-B case) cost its timeout per tag per poll during which no other /// device was read. Devices are now read concurrently, each still serialised on its own socket. /// [Trait("Category", "Unit")] public sealed class FocasConcurrentDeviceReadTests { private const string HostA = "focas://10.0.0.5:8193"; private const string HostB = "focas://10.0.0.6:8193"; /// /// A client that records which device it dialled. Device A's read only completes once device B's read has /// STARTED — so a sequential walk (A first) can never finish A, while a concurrent one does. Also counts /// the peak number of reads in flight on any one client. /// private sealed class OrderingClient(TaskCompletionSource bStarted) : FakeFocasClient { private string _host = ""; private int _inFlight; public int PeakInFlight; public override Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) { _host = address.ToString(); return base.ConnectAsync(address, timeout, ct); } public override async Task<(object? value, uint status)> ReadAsync( FocasAddress address, FocasDataType type, CancellationToken ct) { var now = Interlocked.Increment(ref _inFlight); InterlockedMax(ref PeakInFlight, now); try { if (_host.Contains("10.0.0.6", StringComparison.Ordinal)) bStarted.TrySetResult(); else await bStarted.Task.WaitAsync(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); await Task.Delay(20, ct).ConfigureAwait(false); return ((sbyte)1, FocasStatusMapper.Good); } finally { Interlocked.Decrement(ref _inFlight); } } private static void InterlockedMax(ref int target, int value) { int seen; while ((seen = Volatile.Read(ref target)) < value && Interlocked.CompareExchange(ref target, value, seen) != seen) { } } } /// A stalled device no longer holds every other device's read hostage — and each device stays serialised. [Fact] public async Task Devices_are_read_concurrently_and_each_stays_serialised() { var bStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new FakeFocasClientFactory { Customise = () => new OrderingClient(bStarted) }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")], RawTags = [ FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)), FocasRawTags.Entry(new FocasTagDefinition("raw/A2", "A", "R101", FocasDataType.Byte)), FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R100", FocasDataType.Byte)), ], Probe = new FocasProbeOptions { Enabled = false }, // Off so every tag takes the per-tag path — this pins device-level concurrency, not coalescing. PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false }, }, "focas-p9", factory); await drv.InitializeAsync("{}", CancellationToken.None); var snaps = await drv.ReadAsync(["raw/A1", "raw/A2", "raw/B1"], CancellationToken.None); snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good, "device A's read can only finish once device B's has started — a sequential walk times A out"); factory.Clients.Count.ShouldBe(2); factory.Clients.Cast().ShouldAllBe(c => c.PeakInFlight == 1, "one device's socket is still request→response: never two reads in flight on it"); await drv.ShutdownAsync(CancellationToken.None); } /// Results land at their own indices whatever order the devices finish in. [Fact] public async Task Results_keep_their_request_order_across_devices() { var factory = new FakeFocasClientFactory(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")], RawTags = [ FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)), FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R200", FocasDataType.Byte)), ], Probe = new FocasProbeOptions { Enabled = false }, }, "focas-p9-order", factory) ; factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1, ["R200"] = (sbyte)2 } }; await drv.InitializeAsync("{}", CancellationToken.None); var snaps = await drv.ReadAsync(["raw/B1", "nope", "raw/A1"], CancellationToken.None); snaps[0].Value.ShouldBe((sbyte)2); snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); snaps[2].Value.ShouldBe((sbyte)1); await drv.ShutdownAsync(CancellationToken.None); } }