Files
Joseph Doherty 7740a3bcf9 feat: add JoeAppEngine OPC UA nodes, fix DCL auto-reconnect and quality push
- Add JoeAppEngine folder to OPC UA nodes.json (BTCS, AlarmCntsBySeverity, Scheduler/ScanTime)
- Fix DataConnectionActor: capture Self in PreStart for use from non-actor threads,
  preventing Self.Tell failure in Disconnected event handler
- Implement InstanceActor.HandleConnectionQualityChanged to mark attributes Bad on disconnect
- Fix LmxFakeProxy TagMapper to serialize arrays as JSON instead of "System.Int32[]"
- Allow DataType and DataSourceReference updates in TemplateService.UpdateAttributeAsync
- Update test_infra_opcua.md with JoeAppEngine documentation
2026-03-19 13:27:54 -04:00

272 lines
9.0 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.DataConnectionLayer.Adapters;
namespace ScadaLink.DataConnectionLayer.Tests;
/// <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 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 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>());
}
}