fix(dcl): treat OPC UA BadResourceUnavailable as transient Bad; Bad+null keeps last-known value (ScadaBridge#112)
OtOpcUa's out-of-process script runner publishes 0x80040000 with a null value. - OpcUaDataConnection: logs entry/recovery once per tag, names the symbolic status in read errors; never a disconnect (not in the reconnect status set). - InstanceActor: a Bad update with no value is a quality-only transition (keep value, quality Bad, stream publish, no script/alarm/waiter re-eval), matching the connection-drop rule. Bad with a value is unchanged. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+134
@@ -0,0 +1,134 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#112: OtOpcUa's out-of-process script runner publishes
|
||||
/// <c>BadResourceUnavailable</c> (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.
|
||||
/// </summary>
|
||||
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<OpcUaDataConnection>
|
||||
{
|
||||
public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new();
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter) => Entries.Enqueue((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Subscription_GoodResourceUnavailableGood_IsTransientBadQuality_NotAConnectionFault()
|
||||
{
|
||||
var client = Substitute.For<IOpcUaClient>();
|
||||
var factory = Substitute.For<IOpcUaClientFactory>();
|
||||
factory.Create().Returns(client);
|
||||
client.IsConnected.Returns(true);
|
||||
|
||||
Action<string, object?, DateTime, uint>? publish = null;
|
||||
client.CreateSubscriptionAsync(Tag, Arg.Do<Action<string, object?, DateTime, uint>>(cb => publish = cb),
|
||||
Arg.Any<CancellationToken>())
|
||||
.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<string, string>());
|
||||
var received = new List<TagValue>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[Fact]
|
||||
public async Task Read_ResourceUnavailable_FailsReadWithNamedStatus_NoDisconnect()
|
||||
{
|
||||
var client = Substitute.For<IOpcUaClient>();
|
||||
var factory = Substitute.For<IOpcUaClientFactory>();
|
||||
factory.Create().Returns(client);
|
||||
client.IsConnected.Returns(true);
|
||||
client.ReadValueAsync(Tag, Arg.Any<CancellationToken>())
|
||||
.Returns(((object?)null, DateTime.UtcNow, BadResourceUnavailable));
|
||||
|
||||
var adapter = new OpcUaDataConnection(factory, new CapturingLogger());
|
||||
var disconnected = 0;
|
||||
adapter.Disconnected += () => disconnected++;
|
||||
await adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Other Bad reads still get the symbolic name but not the transient wording.</summary>
|
||||
[Fact]
|
||||
public void DescribeBadRead_OtherCodes_NamedWithoutTransientWording()
|
||||
{
|
||||
var text = OpcUaDataConnection.DescribeBadRead(0x80340000u);
|
||||
Assert.Contains("BadNodeIdUnknown", text);
|
||||
Assert.DoesNotContain("temporarily", text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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 */ })!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user