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:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -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);
}
}
@@ -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&lt;string,string&gt; _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>());
}
}
@@ -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>