using System.Diagnostics; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Coverage for the FOCAS data-plane fix (2026-06-25 equipment-tag investigation): all wire I/O /// on a device's single FOCAS/2 socket must be serialized (request→response cannot interleave) /// and every steady-state read/write must be time-bounded so a stalled CNC read surfaces as a /// recoverable error instead of hanging forever at BadWaitingForInitialData. See /// docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md. /// [Trait("Category", "Unit")] public sealed class FocasIoSerializationTests { private static readonly FocasAddress Macro500 = new(FocasAreaKind.Macro, null, 500, null); // ---- SynchronizedFocasClient: serialization ---- [Fact] public async Task Concurrent_reads_are_serialized_onto_the_inner_client() { var inner = new RecordingClient { ReadDelay = TimeSpan.FromMilliseconds(20) }; await using var _ = NoopDispose(inner); var client = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(5)); var reads = Enumerable.Range(0, 8) .Select(_ => client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None)); await Task.WhenAll(reads); inner.MaxConcurrency.ShouldBe(1); // never more than one wire op on the socket at a time inner.ReadCount.ShouldBe(8); } // ---- SynchronizedFocasClient: per-call timeout ---- [Fact] public async Task A_hung_read_is_bounded_by_the_call_timeout() { var inner = new RecordingClient { BlockReadUntilCancelled = true }; var client = new SynchronizedFocasClient(inner, TimeSpan.FromMilliseconds(100)); var sw = Stopwatch.StartNew(); await Should.ThrowAsync( () => client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None)); sw.Stop(); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // bounded, not the indefinite OS TCP wait } [Fact] public async Task A_hung_read_does_not_hold_the_socket_for_the_next_call() { // The gate must be released when a bounded call times out, otherwise one stall would wedge // every subsequent op on the device. Read #1 hangs (times out); read #2 must still proceed. var inner = new TimeoutThenServeClient { FirstCallBlocks = true }; var client = new SynchronizedFocasClient(inner, TimeSpan.FromMilliseconds(100)); await Should.ThrowAsync( () => client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None)); var (value, status) = await client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None); status.ShouldBe(FocasStatusMapper.Good); value.ShouldBe(42); } [Fact] public async Task Probe_is_not_bounded_by_the_call_timeout() { // Connect/Probe carry their own budgets; the decorator must not shrink them to its read budget. var inner = new RecordingClient { ProbeDelay = TimeSpan.FromMilliseconds(200) }; var client = new SynchronizedFocasClient(inner, TimeSpan.FromMilliseconds(50)); var result = await client.ProbeAsync(CancellationToken.None); result.ShouldBeTrue(); } [Fact] public async Task Zero_call_timeout_disables_the_per_call_bound() { var inner = new RecordingClient { ReadDelay = TimeSpan.FromMilliseconds(120) }; var client = new SynchronizedFocasClient(inner, TimeSpan.Zero); var (value, status) = await client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None); status.ShouldBe(FocasStatusMapper.Good); value.ShouldBe(42); } [Fact] public void Dispose_disposes_the_inner_client() { var inner = new RecordingClient(); var client = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(1)); client.Dispose(); inner.DisposeCount.ShouldBe(1); } // ---- Driver level: a timed-out read overwrites the seed with a recoverable status ---- [Fact] public async Task Driver_read_that_times_out_returns_BadCommunicationError_not_a_hang() { var factory = new FakeFocasClientFactory { Customise = () => new RecordingClient { BlockReadUntilCancelled = true } }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Tags = [new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)], Probe = new FocasProbeOptions { Enabled = false }, Timeout = TimeSpan.FromMilliseconds(150), }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var sw = Stopwatch.StartNew(); var snap = (await drv.ReadAsync(["CustomVar"], CancellationToken.None)).Single(); sw.Stop(); snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // bounded by Timeout, not hung } [Fact] public async Task Driver_read_does_not_propagate_a_call_timeout_as_cancellation() { // The per-call timeout must NOT bubble out of ReadAsync as OperationCanceledException — that // would abort the whole poll batch. It must be caught and turned into a per-tag Bad status. var factory = new FakeFocasClientFactory { Customise = () => new RecordingClient { BlockReadUntilCancelled = true } }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Tags = [new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)], Probe = new FocasProbeOptions { Enabled = false }, Timeout = TimeSpan.FromMilliseconds(120), }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); // Should complete (not throw) with a Bad snapshot, even though the caller's token is never cancelled. var snaps = await drv.ReadAsync(["CustomVar"], CancellationToken.None); snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); } private static DisposeGuard NoopDispose(IDisposable d) => new(d); private sealed class DisposeGuard(IDisposable inner) : IAsyncDisposable { public ValueTask DisposeAsync() { inner.Dispose(); return ValueTask.CompletedTask; } } /// Fake that records concurrency + optionally delays/blocks reads and probes. private class RecordingClient : FakeFocasClient { private int _current; public int MaxConcurrency; public int ReadCount; public TimeSpan ReadDelay = TimeSpan.Zero; public bool BlockReadUntilCancelled; public TimeSpan ProbeDelay = TimeSpan.Zero; public override async Task<(object? value, uint status)> ReadAsync( FocasAddress address, FocasDataType type, CancellationToken ct) { Interlocked.Increment(ref ReadCount); var observed = Interlocked.Increment(ref _current); InterlockedMax(ref MaxConcurrency, observed); try { if (BlockReadUntilCancelled) await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); else if (ReadDelay > TimeSpan.Zero) await Task.Delay(ReadDelay, ct).ConfigureAwait(false); return ((object?)42, FocasStatusMapper.Good); } finally { Interlocked.Decrement(ref _current); } } public override async Task ProbeAsync(CancellationToken ct) { if (ProbeDelay > TimeSpan.Zero) await Task.Delay(ProbeDelay, ct).ConfigureAwait(false); return true; } private static void InterlockedMax(ref int target, int value) { int seen; do { seen = Volatile.Read(ref target); if (value <= seen) return; } while (Interlocked.CompareExchange(ref target, value, seen) != seen); } } /// First read blocks until cancelled; subsequent reads serve a Good value immediately. private sealed class TimeoutThenServeClient : FakeFocasClient { public bool FirstCallBlocks; private int _calls; public override async Task<(object? value, uint status)> ReadAsync( FocasAddress address, FocasDataType type, CancellationToken ct) { var n = Interlocked.Increment(ref _calls); if (n == 1 && FirstCallBlocks) await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); return ((object?)42, FocasStatusMapper.Good); } } }