refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-34: Tests for protocol extensibility via DataConnectionFactory.
|
||||
/// </summary>
|
||||
public class DataConnectionFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_OpcUa_ReturnsOpcUaAdapter()
|
||||
{
|
||||
var factory = new DataConnectionFactory(NullLoggerFactory.Instance);
|
||||
|
||||
var connection = factory.Create("OpcUa", new Dictionary<string, string>());
|
||||
|
||||
Assert.IsType<OpcUaDataConnection>(connection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_CaseInsensitive()
|
||||
{
|
||||
var factory = new DataConnectionFactory(NullLoggerFactory.Instance);
|
||||
|
||||
var connection = factory.Create("opcua", new Dictionary<string, string>());
|
||||
|
||||
Assert.IsType<OpcUaDataConnection>(connection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_UnknownProtocol_Throws()
|
||||
{
|
||||
var factory = new DataConnectionFactory(NullLoggerFactory.Instance);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
factory.Create("UnknownProtocol", new Dictionary<string, string>()));
|
||||
|
||||
Assert.Contains("Unknown protocol type", ex.Message);
|
||||
Assert.Contains("OpcUa", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterAdapter_ExtendsFactory()
|
||||
{
|
||||
var factory = new DataConnectionFactory(NullLoggerFactory.Instance);
|
||||
|
||||
// WP-34: Adding new protocol = register adapter
|
||||
factory.RegisterAdapter("Custom", _ => new OpcUaDataConnection(
|
||||
new DefaultOpcUaClientFactory(), NullLogger<OpcUaDataConnection>.Instance));
|
||||
|
||||
var connection = factory.Create("Custom", new Dictionary<string, string>());
|
||||
Assert.NotNull(connection);
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-34: Tests for DataConnectionManagerActor routing and lifecycle.
|
||||
/// </summary>
|
||||
public class DataConnectionManagerActorTests : TestKit
|
||||
{
|
||||
private readonly IDataConnectionFactory _mockFactory;
|
||||
private readonly DataConnectionOptions _options;
|
||||
private readonly ISiteHealthCollector _mockHealthCollector;
|
||||
|
||||
public DataConnectionManagerActorTests()
|
||||
: base(@"akka.loglevel = DEBUG")
|
||||
{
|
||||
_mockFactory = Substitute.For<IDataConnectionFactory>();
|
||||
_mockHealthCollector = Substitute.For<ISiteHealthCollector>();
|
||||
_options = new DataConnectionOptions
|
||||
{
|
||||
ReconnectInterval = TimeSpan.FromMilliseconds(100),
|
||||
TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200)
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteToUnknownConnection_ReturnsError()
|
||||
{
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_mockFactory, _options, _mockHealthCollector)));
|
||||
|
||||
manager.Tell(new WriteTagRequest(
|
||||
"corr1", "nonexistent", "tag1", 42, DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<WriteTagResponse>();
|
||||
Assert.False(response.Success);
|
||||
Assert.Contains("Unknown connection", response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscribeToUnknownConnection_ReturnsError()
|
||||
{
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_mockFactory, _options, _mockHealthCollector)));
|
||||
|
||||
manager.Tell(new SubscribeTagsRequest(
|
||||
"corr1", "inst1", "nonexistent", ["tag1"], DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<SubscribeTagsResponse>();
|
||||
Assert.False(response.Success);
|
||||
Assert.Contains("Unknown connection", response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL002_ConnectionActorCrash_PreservesSubscriptionState()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-002. The supervisor used
|
||||
// Directive.Restart, which discards the connection actor's in-memory
|
||||
// subscription registry — breaking the design doc's "transparent
|
||||
// re-subscribe" guarantee (subscribers are never re-subscribed and sit at
|
||||
// stale quality forever). After the fix the supervisor uses Resume, which
|
||||
// keeps the actor instance and its state across a transient exception.
|
||||
var mockAdapter = Substitute.For<IDataConnection>();
|
||||
mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
mockAdapter.Status.Returns(ConnectionHealth.Connected);
|
||||
mockAdapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
|
||||
.Returns("sub-001");
|
||||
mockAdapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ReadResult(false, null, null));
|
||||
// A write throws synchronously, escaping the message handler and crashing
|
||||
// the connection actor — exercising the supervisor strategy.
|
||||
mockAdapter.WriteAsync(Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.Returns<Task<WriteResult>>(_ => throw new InvalidOperationException("boom"));
|
||||
|
||||
_mockFactory.Create("OpcUa", Arg.Any<IDictionary<string, string>>()).Returns(mockAdapter);
|
||||
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_mockFactory, _options, _mockHealthCollector)));
|
||||
|
||||
manager.Tell(new CreateConnectionCommand("conn1", "OpcUa", new Dictionary<string, string>(), null, 3));
|
||||
await Task.Delay(300); // connection actor reaches Connected
|
||||
|
||||
// Register a subscription.
|
||||
manager.Tell(new SubscribeTagsRequest("c1", "inst1", "conn1", ["tag1"], DateTimeOffset.UtcNow));
|
||||
ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(3));
|
||||
|
||||
// Crash the connection actor via a synchronously-throwing write.
|
||||
manager.Tell(new WriteTagRequest("c2", "conn1", "tag1", 42, DateTimeOffset.UtcNow));
|
||||
await Task.Delay(300); // supervisor handles the failure
|
||||
|
||||
// After the crash the subscription state must survive: the health report
|
||||
// still shows the subscribed/resolved tag. With Restart it would be 0.
|
||||
manager.Tell(new GetAllHealthReports());
|
||||
var report = ExpectMsg<DataConnectionHealthReport>(TimeSpan.FromSeconds(3));
|
||||
Assert.Equal(1, report.TotalSubscribedTags);
|
||||
Assert.Equal(1, report.ResolvedTags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateConnection_UsesFactory()
|
||||
{
|
||||
var mockAdapter = Substitute.For<IDataConnection>();
|
||||
mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
_mockFactory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
|
||||
.Returns(mockAdapter);
|
||||
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_mockFactory, _options, _mockHealthCollector)));
|
||||
|
||||
manager.Tell(new CreateConnectionCommand(
|
||||
"conn1", "OpcUa", new Dictionary<string, string>(), null, 3));
|
||||
|
||||
// Factory should have been called
|
||||
AwaitCondition(() =>
|
||||
_mockFactory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
||||
TimeSpan.FromSeconds(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// DataConnectionLayer-003: structural regression guard. RealOpcUaClient's
|
||||
/// monitored-item / callback maps are read from the OPC UA SDK's publish threads
|
||||
/// concurrently with subscribe/disconnect mutations on other threads. They must be
|
||||
/// concurrent collections, not plain Dictionary. This is verified structurally
|
||||
/// because RealOpcUaClient wraps concrete OPC Foundation SDK types and cannot be
|
||||
/// exercised without a live OPC UA server.
|
||||
/// </summary>
|
||||
public class RealOpcUaClientThreadSafetyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("_callbacks")]
|
||||
[InlineData("_monitoredItems")]
|
||||
public void DCL003_SharedDictionaryFields_AreConcurrentCollections(string fieldName)
|
||||
{
|
||||
var field = typeof(RealOpcUaClient)
|
||||
.GetField(fieldName,
|
||||
System.Reflection.BindingFlags.Instance |
|
||||
System.Reflection.BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(field);
|
||||
|
||||
var fieldType = field!.FieldType;
|
||||
Assert.True(
|
||||
fieldType.IsGenericType &&
|
||||
fieldType.GetGenericTypeDefinition() == typeof(System.Collections.Concurrent.ConcurrentDictionary<,>),
|
||||
$"RealOpcUaClient.{fieldName} must be a ConcurrentDictionary<,> for thread safety, " +
|
||||
$"but was {fieldType.Name}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DataConnectionLayer-019: <see cref="OpcUaDataConnection"/> previously kept a
|
||||
/// dead <c>Dictionary<string,string> _subscriptionHandles</c> field that was
|
||||
/// written and removed across thread-pool continuations but never read. Plain
|
||||
/// Dictionary writes from concurrent post-await continuations are racy; the
|
||||
/// field was a latent bug waiting for any future reader. The fix deletes the
|
||||
/// field rather than converting it to ConcurrentDictionary (bookkeeping already
|
||||
/// lives in <c>RealOpcUaClient._monitoredItems/_callbacks</c> and
|
||||
/// <c>DataConnectionActor._subscriptionIds</c>). This test guards against
|
||||
/// regression — anyone re-introducing a non-concurrent shared dictionary on
|
||||
/// the adapter must justify it explicitly.
|
||||
/// </summary>
|
||||
public class OpcUaDataConnectionThreadSafetyTests
|
||||
{
|
||||
[Fact]
|
||||
public void DCL019_OpcUaDataConnection_HasNoNonConcurrentSharedDictionary()
|
||||
{
|
||||
// Reflection-walk every instance field on the adapter. Any
|
||||
// System.Collections.Generic.Dictionary<,> field would be a regression:
|
||||
// either dead state (return it) or live state mutated from continuations
|
||||
// (convert to ConcurrentDictionary). Either way, fail the test.
|
||||
var dictionaryFields = typeof(OpcUaDataConnection)
|
||||
.GetFields(System.Reflection.BindingFlags.Instance |
|
||||
System.Reflection.BindingFlags.NonPublic |
|
||||
System.Reflection.BindingFlags.Public)
|
||||
.Where(f => f.FieldType.IsGenericType &&
|
||||
f.FieldType.GetGenericTypeDefinition() == typeof(Dictionary<,>))
|
||||
.Select(f => f.Name)
|
||||
.ToList();
|
||||
|
||||
Assert.True(dictionaryFields.Count == 0,
|
||||
$"OpcUaDataConnection must not hold a non-concurrent Dictionary<,> field; " +
|
||||
$"found: {string.Join(", ", dictionaryFields)}. See DCL-019.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DataConnectionLayer-012: secure-by-default certificate handling.
|
||||
/// </summary>
|
||||
public class OpcUaCertificateDefaultTests
|
||||
{
|
||||
[Fact]
|
||||
public void DCL012_OpcUaConnectionOptions_AutoAcceptUntrustedCerts_DefaultsToFalse()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-012. AutoAcceptUntrustedCerts defaulted
|
||||
// to true, accepting every server certificate unconditionally and defeating the
|
||||
// Sign / SignAndEncrypt security modes against an active man-in-the-middle. A
|
||||
// secure-by-default posture rejects untrusted certs unless explicitly opted in.
|
||||
var options = new OpcUaConnectionOptions();
|
||||
Assert.False(options.AutoAcceptUntrustedCerts);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DataConnectionLayer-014: the DCL-012 auto-accept-certificate security warning is
|
||||
/// only effective if RealOpcUaClient is built with a real logger. The only production
|
||||
/// path that constructs one is RealOpcUaClientFactory.Create(); that factory must
|
||||
/// thread a logger through, otherwise the warning sinks into NullLogger and an
|
||||
/// operator who enables AutoAcceptUntrustedCerts sees no signal anywhere.
|
||||
/// </summary>
|
||||
public class RealOpcUaClientFactoryLoggerTests
|
||||
{
|
||||
private static ILogger<RealOpcUaClient> ReadLogger(RealOpcUaClient client)
|
||||
{
|
||||
var field = typeof(RealOpcUaClient).GetField("_logger",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.NotNull(field);
|
||||
return (ILogger<RealOpcUaClient>)field!.GetValue(client)!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DCL014_RealOpcUaClientFactory_CreatesClientWithRealLogger()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-014. RealOpcUaClientFactory.Create()
|
||||
// constructed `new RealOpcUaClient(_globalOptions)` with no logger, so the
|
||||
// DCL-012 man-in-the-middle warning was always discarded by NullLogger in
|
||||
// production. After the fix the factory accepts an ILoggerFactory and passes a
|
||||
// real ILogger<RealOpcUaClient> into every client it creates.
|
||||
using var loggerFactory = LoggerFactory.Create(b => { });
|
||||
var factory = new RealOpcUaClientFactory(new OpcUaGlobalOptions(), loggerFactory);
|
||||
|
||||
var client = factory.Create();
|
||||
|
||||
var logger = ReadLogger((RealOpcUaClient)client);
|
||||
Assert.NotSame(NullLogger<RealOpcUaClient>.Instance, logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DCL014_DataConnectionFactory_ThreadsLoggerToRealOpcUaClient()
|
||||
{
|
||||
// The full production wiring: DataConnectionFactory holds an ILoggerFactory and
|
||||
// registers the OpcUa adapter. The RealOpcUaClient it ultimately builds must end
|
||||
// up with a real (non-Null) logger so the auto-accept-cert warning is visible.
|
||||
using var loggerFactory = LoggerFactory.Create(b => { });
|
||||
var dataConnectionFactory = new DataConnectionFactory(loggerFactory);
|
||||
var adapter = (OpcUaDataConnection)dataConnectionFactory.Create(
|
||||
"OpcUa", new Dictionary<string, string>());
|
||||
|
||||
// Reach the RealOpcUaClient the adapter would create on connect.
|
||||
var clientFactoryField = typeof(OpcUaDataConnection).GetField("_clientFactory",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.NotNull(clientFactoryField);
|
||||
var clientFactory = (RealOpcUaClientFactory)clientFactoryField!.GetValue(adapter)!;
|
||||
var client = (RealOpcUaClient)clientFactory.Create();
|
||||
|
||||
var logger = ReadLogger(client);
|
||||
Assert.NotSame(NullLogger<RealOpcUaClient>.Instance, logger);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DataConnectionLayer-009: failover-stability tunables must be configurable.
|
||||
/// </summary>
|
||||
public class DataConnectionOptionsStabilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void DCL009_StableConnectionThreshold_IsConfigurable_WithSixtySecondDefault()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-009. The unstable-disconnect failover
|
||||
// path used a hard-coded 60s StableConnectionThreshold constant inside
|
||||
// DataConnectionActor. It must live on DataConnectionOptions like the other
|
||||
// tunables (ReconnectInterval, TagResolutionRetryInterval, WriteTimeout) so it
|
||||
// is configurable via appsettings.json.
|
||||
var options = new DataConnectionOptions();
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), options.StableConnectionThreshold);
|
||||
|
||||
options.StableConnectionThreshold = TimeSpan.FromSeconds(30);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), options.StableConnectionThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-7: Tests for OPC UA adapter.
|
||||
/// </summary>
|
||||
public class OpcUaDataConnectionTests
|
||||
{
|
||||
private readonly IOpcUaClient _mockClient;
|
||||
private readonly IOpcUaClientFactory _mockFactory;
|
||||
private readonly OpcUaDataConnection _adapter;
|
||||
|
||||
public OpcUaDataConnectionTests()
|
||||
{
|
||||
_mockClient = Substitute.For<IOpcUaClient>();
|
||||
_mockFactory = Substitute.For<IOpcUaClientFactory>();
|
||||
_mockFactory.Create().Returns(_mockClient);
|
||||
_adapter = new OpcUaDataConnection(_mockFactory, NullLogger<OpcUaDataConnection>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_SetsStatusToConnected()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>
|
||||
{
|
||||
["EndpointUrl"] = "opc.tcp://localhost:4840"
|
||||
});
|
||||
|
||||
Assert.Equal(ConnectionHealth.Connected, _adapter.Status);
|
||||
await _mockClient.Received(1).ConnectAsync("opc.tcp://localhost:4840", Arg.Any<OpcUaConnectionOptions?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disconnect_SetsStatusToDisconnected()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
await _adapter.DisconnectAsync();
|
||||
|
||||
Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL013_ConcurrentConnectionLost_RaisesDisconnectedExactlyOnce()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-013. RaiseDisconnected used a
|
||||
// non-atomic check-then-set on a volatile bool: two threads racing through it
|
||||
// (e.g. the keep-alive thread and a ReadAsync failure path, both routed via
|
||||
// OnClientConnectionLost) could both observe _disconnectFired == false and both
|
||||
// invoke Disconnected. The guard is now an atomic Interlocked.Exchange, so a
|
||||
// burst of concurrent connection-lost callbacks fires the event exactly once.
|
||||
// Repeat the burst: reconnecting between rounds re-arms the guard, so each
|
||||
// round must independently fire Disconnected exactly once. Repetition makes
|
||||
// the (timing-dependent) non-atomic race overwhelmingly likely to be caught.
|
||||
const int rounds = 25;
|
||||
const int threads = 32;
|
||||
for (var round = 0; round < rounds; round++)
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
var fired = 0;
|
||||
void Handler() => Interlocked.Increment(ref fired);
|
||||
_adapter.Disconnected += Handler;
|
||||
|
||||
// Fan out: many threads raise the client's ConnectionLost event together.
|
||||
using (var ready = new Barrier(threads))
|
||||
{
|
||||
var tasks = Enumerable.Range(0, threads).Select(_ => Task.Run(() =>
|
||||
{
|
||||
ready.SignalAndWait();
|
||||
_mockClient.ConnectionLost += Raise.Event<Action>();
|
||||
})).ToArray();
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
_adapter.Disconnected -= Handler;
|
||||
Assert.Equal(1, fired);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_DelegatesAndReturnsId()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.CreateSubscriptionAsync(Arg.Any<string>(), Arg.Any<Action<string, object?, DateTime, uint>>(), Arg.Any<CancellationToken>())
|
||||
.Returns("sub-001");
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
var subId = await _adapter.SubscribeAsync("ns=2;s=Tag1", (_, _) => { });
|
||||
|
||||
Assert.Equal("sub-001", subId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_Success_ReturnsGoodResult()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.WriteValueAsync("ns=2;s=Tag1", 42, Arg.Any<CancellationToken>())
|
||||
.Returns((uint)0);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
var result = await _adapter.WriteAsync("ns=2;s=Tag1", 42);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_Failure_ReturnsError()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.WriteValueAsync("ns=2;s=Tag1", 42, Arg.Any<CancellationToken>())
|
||||
.Returns(0x80000000u);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
var result = await _adapter.WriteAsync("ns=2;s=Tag1", 42);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("0x80000000", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_BadStatus_ReturnsBadResult()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.ReadValueAsync("ns=2;s=Tag1", Arg.Any<CancellationToken>())
|
||||
.Returns((null, DateTime.UtcNow, 0x80000000u));
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
var result = await _adapter.ReadAsync("ns=2;s=Tag1");
|
||||
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_GoodStatus_ReturnsValue()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.ReadValueAsync("ns=2;s=Tag1", Arg.Any<CancellationToken>())
|
||||
.Returns((42.5, DateTime.UtcNow, 0u));
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
var result = await _adapter.ReadAsync("ns=2;s=Tag1");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.Equal(42.5, result.Value!.Value);
|
||||
Assert.Equal(QualityCode.Good, result.Value.Quality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadBatch_ReadsAllTags()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.ReadValueAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns((1.0, DateTime.UtcNow, 0u));
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
var results = await _adapter.ReadBatchAsync(["tag1", "tag2", "tag3"]);
|
||||
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.All(results.Values, r => Assert.True(r.Success));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL007_ReadBatch_ReturnsPerTagResults_WhenOneTagFails()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-007. ReadBatchAsync looped calling
|
||||
// ReadAsync per tag; ReadAsync re-throws any non-cancellation exception, so a
|
||||
// single failing tag aborted the whole batch and the caller got NO results for
|
||||
// the tags that did read successfully — even though ReadResult already carries
|
||||
// a per-tag Success/ErrorMessage shape. After the fix the batch catches per-tag
|
||||
// exceptions and returns a complete map.
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.ReadValueAsync("good1", Arg.Any<CancellationToken>())
|
||||
.Returns((1.0, DateTime.UtcNow, 0u));
|
||||
_mockClient.ReadValueAsync("bad", Arg.Any<CancellationToken>())
|
||||
.Returns<(object?, DateTime, uint)>(_ => throw new InvalidOperationException("node not found"));
|
||||
_mockClient.ReadValueAsync("good2", Arg.Any<CancellationToken>())
|
||||
.Returns((2.0, DateTime.UtcNow, 0u));
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
var results = await _adapter.ReadBatchAsync(["good1", "bad", "good2"]);
|
||||
|
||||
// Every requested tag is present in the result map.
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.True(results["good1"].Success);
|
||||
Assert.True(results["good2"].Success);
|
||||
// The failing tag is reported as a failed ReadResult, not by aborting the batch.
|
||||
Assert.False(results["bad"].Success);
|
||||
Assert.NotNull(results["bad"].ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL017_WriteBatch_ReturnsPerTagResults_WhenConnectionDropsMidBatch()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-017. WriteBatchAsync looped calling
|
||||
// WriteAsync per tag; WriteAsync first calls EnsureConnected(), which throws
|
||||
// InvalidOperationException when the client is disconnected. WriteBatchAsync did
|
||||
// not catch that, so a connection dropping partway through a batch made the whole
|
||||
// WriteBatchAsync throw — the caller lost the per-tag outcomes for the tags that
|
||||
// already wrote. After the fix (mirroring DCL-007's ReadBatchAsync) each per-tag
|
||||
// failure is recorded as a failed WriteResult and the batch returns a complete map.
|
||||
var writeCount = 0;
|
||||
// First write succeeds; then the client "disconnects" so EnsureConnected throws.
|
||||
_mockClient.IsConnected.Returns(_ => Interlocked.Increment(ref writeCount) <= 1);
|
||||
_mockClient.WriteValueAsync(Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.Returns((uint)0);
|
||||
|
||||
// Connect leaves IsConnected true for the first WriteAsync's EnsureConnected check.
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
// Re-arm: IsConnected true for tag1's check, false for tag2 and tag3.
|
||||
var checks = 0;
|
||||
_mockClient.IsConnected.Returns(_ => Interlocked.Increment(ref checks) <= 1);
|
||||
|
||||
var results = await _adapter.WriteBatchAsync(new Dictionary<string, object?>
|
||||
{
|
||||
["tag1"] = 1,
|
||||
["tag2"] = 2,
|
||||
["tag3"] = 3
|
||||
});
|
||||
|
||||
// Every requested tag is present in the result map — the batch was not aborted.
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.True(results["tag1"].Success);
|
||||
// tag2 and tag3 fail at the connection check but are reported per-tag.
|
||||
Assert.False(results["tag2"].Success);
|
||||
Assert.NotNull(results["tag2"].ErrorMessage);
|
||||
Assert.False(results["tag3"].Success);
|
||||
Assert.NotNull(results["tag3"].ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL017_WriteBatch_CancellationAbortsWholeBatch()
|
||||
{
|
||||
// Companion guard: a cancelled batch must still abort as a whole — only
|
||||
// connection/device faults are demoted to per-tag results, never cancellation.
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
_mockClient.WriteValueAsync(Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.Returns<uint>(_ => throw new OperationCanceledException());
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
_adapter.WriteBatchAsync(new Dictionary<string, object?> { ["tag1"] = 1 }, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotConnected_ThrowsOnOperations()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(false);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_adapter.ReadAsync("tag1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_CleansUp()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
await _adapter.DisposeAsync();
|
||||
|
||||
Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
|
||||
}
|
||||
|
||||
// --- Configuration Parsing ---
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_ParsesAllConfigurationKeys()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>
|
||||
{
|
||||
["EndpointUrl"] = "opc.tcp://myserver:4840",
|
||||
["SessionTimeoutMs"] = "120000",
|
||||
["OperationTimeoutMs"] = "30000",
|
||||
["PublishingIntervalMs"] = "500",
|
||||
["KeepAliveCount"] = "5",
|
||||
["LifetimeCount"] = "15",
|
||||
["MaxNotificationsPerPublish"] = "200",
|
||||
["SamplingIntervalMs"] = "250",
|
||||
["QueueSize"] = "20",
|
||||
["SecurityMode"] = "SignAndEncrypt",
|
||||
["AutoAcceptUntrustedCerts"] = "false"
|
||||
});
|
||||
|
||||
await _mockClient.Received(1).ConnectAsync(
|
||||
"opc.tcp://myserver:4840",
|
||||
Arg.Is<OpcUaConnectionOptions?>(o =>
|
||||
o != null &&
|
||||
o.SessionTimeoutMs == 120000 &&
|
||||
o.OperationTimeoutMs == 30000 &&
|
||||
o.PublishingIntervalMs == 500 &&
|
||||
o.KeepAliveCount == 5 &&
|
||||
o.LifetimeCount == 15 &&
|
||||
o.MaxNotificationsPerPublish == 200 &&
|
||||
o.SamplingIntervalMs == 250 &&
|
||||
o.QueueSize == 20 &&
|
||||
o.SecurityMode == "SignAndEncrypt" &&
|
||||
o.AutoAcceptUntrustedCerts == false),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_UsesDefaults_WhenKeysNotProvided()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
await _mockClient.Received(1).ConnectAsync(
|
||||
"opc.tcp://localhost:4840",
|
||||
Arg.Is<OpcUaConnectionOptions?>(o =>
|
||||
o != null &&
|
||||
o.SessionTimeoutMs == 60000 &&
|
||||
o.OperationTimeoutMs == 15000 &&
|
||||
o.PublishingIntervalMs == 1000 &&
|
||||
o.KeepAliveCount == 10 &&
|
||||
o.LifetimeCount == 30 &&
|
||||
o.MaxNotificationsPerPublish == 100 &&
|
||||
o.SamplingIntervalMs == 1000 &&
|
||||
o.QueueSize == 10 &&
|
||||
o.SecurityMode == "None" &&
|
||||
o.AutoAcceptUntrustedCerts == true),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_IgnoresInvalidNumericValues()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>
|
||||
{
|
||||
["SessionTimeoutMs"] = "notanumber",
|
||||
["OperationTimeoutMs"] = "",
|
||||
["PublishingIntervalMs"] = "abc",
|
||||
["QueueSize"] = "12.5"
|
||||
});
|
||||
|
||||
await _mockClient.Received(1).ConnectAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<OpcUaConnectionOptions?>(o =>
|
||||
o != null &&
|
||||
o.SessionTimeoutMs == 60000 &&
|
||||
o.OperationTimeoutMs == 15000 &&
|
||||
o.PublishingIntervalMs == 1000 &&
|
||||
o.QueueSize == 10),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_ParsesSecurityMode()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>
|
||||
{
|
||||
["SecurityMode"] = "Sign"
|
||||
});
|
||||
|
||||
await _mockClient.Received(1).ConnectAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<OpcUaConnectionOptions?>(o => o != null && o.SecurityMode == "Sign"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_ParsesAutoAcceptCerts()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>
|
||||
{
|
||||
["AutoAcceptUntrustedCerts"] = "false"
|
||||
});
|
||||
|
||||
await _mockClient.Received(1).ConnectAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<OpcUaConnectionOptions?>(o => o != null && o.AutoAcceptUntrustedCerts == false),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user