using System.Collections.Concurrent; using System.Reflection; using Microsoft.Extensions.Logging; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; /// /// ScadaBridge#112: OtOpcUa's out-of-process script runner publishes /// BadResourceUnavailable (0x80040000) — with a null value — on script-computed nodes /// when its worker died or is not ready. The OPC UA adapter must treat it as a plain, transient /// Bad quality on the tag: no disconnect, no subscription teardown, distinct log lines on entry /// and recovery, and a read error that names the status. /// public class OpcUaResourceUnavailableTests { private const uint BadResourceUnavailable = 0x80040000u; private const string Tag = "nsu=https://zb.com/otopcua/uns;s=Line1.Pump.Calc"; private sealed class CapturingLogger : ILogger { public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => Entries.Enqueue((logLevel, formatter(state, exception))); } /// /// Good / BadResourceUnavailable(null) / Good on a monitored item maps to Good / Bad / Good /// tag quality, never raises Disconnected, never removes the subscription, and logs the /// unavailable entry once (not per notification) plus one recovery line. /// [Fact] public async Task Subscription_GoodResourceUnavailableGood_IsTransientBadQuality_NotAConnectionFault() { var client = Substitute.For(); var factory = Substitute.For(); factory.Create().Returns(client); client.IsConnected.Returns(true); Action? publish = null; client.CreateSubscriptionAsync(Tag, Arg.Do>(cb => publish = cb), Arg.Any()) .Returns("sub-1"); var logger = new CapturingLogger(); var adapter = new OpcUaDataConnection(factory, logger); var disconnected = 0; adapter.Disconnected += () => Interlocked.Increment(ref disconnected); await adapter.ConnectAsync(new Dictionary()); var received = new List(); await adapter.SubscribeAsync(Tag, (_, v) => received.Add(v)); Assert.NotNull(publish); publish!(Tag, 42.0, DateTime.UtcNow, 0u); publish(Tag, null, DateTime.UtcNow, BadResourceUnavailable); publish(Tag, null, DateTime.UtcNow, BadResourceUnavailable); // repeated: no second log line publish(Tag, 43.0, DateTime.UtcNow, 0u); Assert.Equal( new[] { QualityCode.Good, QualityCode.Bad, QualityCode.Bad, QualityCode.Good }, received.Select(v => v.Quality).ToArray()); Assert.Null(received[1].Value); Assert.Equal(43.0, received[3].Value); Assert.Equal(0, disconnected); Assert.Equal(ConnectionHealth.Connected, adapter.Status); await client.DidNotReceiveWithAnyArgs().RemoveSubscriptionAsync(default!, default); await client.DidNotReceiveWithAnyArgs().DisconnectAsync(default); var unavailable = logger.Entries.Where(e => e.Message.Contains("reports BadResourceUnavailable")).ToList(); var recovered = logger.Entries.Where(e => e.Message.Contains("recovered") && e.Message.Contains(Tag)).ToList(); Assert.Single(unavailable); Assert.Contains(Tag, unavailable[0].Message); Assert.Single(recovered); } /// A read that returns BadResourceUnavailable fails the read without a disconnect and /// names the symbolic status so it reads as a transient source-side condition. [Fact] public async Task Read_ResourceUnavailable_FailsReadWithNamedStatus_NoDisconnect() { var client = Substitute.For(); var factory = Substitute.For(); factory.Create().Returns(client); client.IsConnected.Returns(true); client.ReadValueAsync(Tag, Arg.Any()) .Returns(((object?)null, DateTime.UtcNow, BadResourceUnavailable)); var adapter = new OpcUaDataConnection(factory, new CapturingLogger()); var disconnected = 0; adapter.Disconnected += () => disconnected++; await adapter.ConnectAsync(new Dictionary()); var result = await adapter.ReadAsync(Tag); Assert.False(result.Success); Assert.Contains("0x80040000", result.ErrorMessage); Assert.Contains("BadResourceUnavailable", result.ErrorMessage); Assert.Contains("temporarily unable", result.ErrorMessage); Assert.Equal(0, disconnected); Assert.Equal(ConnectionHealth.Connected, adapter.Status); } /// Other Bad reads still get the symbolic name but not the transient wording. [Fact] public void DescribeBadRead_OtherCodes_NamedWithoutTransientWording() { var text = OpcUaDataConnection.DescribeBadRead(0x80340000u); Assert.Contains("BadNodeIdUnknown", text); Assert.DoesNotContain("temporarily", text); } /// /// BadResourceUnavailable is a per-node status: the DataConnectionActor must never classify it /// as a session/secure-channel failure, which would drive a reconnect and a subscription rebuild. /// [Fact] public void ResourceUnavailable_IsNotConnectionLevelStatus() { var method = typeof(DataConnectionActor).GetMethod("IsConnectionLevelStatus", BindingFlags.NonPublic | BindingFlags.Static); Assert.NotNull(method); Assert.False((bool)method!.Invoke(null, new object[] { BadResourceUnavailable })!); // Positive control: a genuine session code IS connection-level. Assert.True((bool)method.Invoke(null, new object[] { 0x80250000u /* BadSessionIdInvalid */ })!); } }