Phase 3B: Site I/O & Observability — Communication, DCL, Script/Alarm actors, Health, Event Logging

Communication Layer (WP-1–5):
- 8 message patterns with correlation IDs, per-pattern timeouts
- Central/Site communication actors, transport heartbeat config
- Connection failure handling (no central buffering, debug streams killed)

Data Connection Layer (WP-6–14, WP-34):
- Connection actor with Become/Stash lifecycle (Connecting/Connected/Reconnecting)
- OPC UA + LmxProxy adapters behind IDataConnection
- Auto-reconnect, bad quality propagation, transparent re-subscribe
- Write-back, tag path resolution with retry, health reporting
- Protocol extensibility via DataConnectionFactory

Site Runtime (WP-15–25, WP-32–33):
- ScriptActor/ScriptExecutionActor (triggers, concurrent execution, blocking I/O dispatcher)
- AlarmActor/AlarmExecutionActor (ValueMatch/RangeViolation/RateOfChange, in-memory state)
- SharedScriptLibrary (inline execution), ScriptRuntimeContext (API)
- ScriptCompilationService (Roslyn, forbidden API enforcement, execution timeout)
- Recursion limit (default 10), call direction enforcement
- SiteStreamManager (per-subscriber bounded buffers, fire-and-forget)
- Debug view backend (snapshot + stream), concurrency serialization
- Local artifact storage (4 SQLite tables)

Health Monitoring (WP-26–28):
- SiteHealthCollector (thread-safe counters, connection state)
- HealthReportSender (30s interval, monotonic sequence numbers)
- CentralHealthAggregator (offline detection 60s, online recovery)

Site Event Logging (WP-29–31):
- SiteEventLogger (SQLite, 6 event categories, ISO 8601 UTC)
- EventLogPurgeService (30-day retention, 1GB cap)
- EventLogQueryService (filters, keyword search, keyset pagination)

541 tests pass, zero warnings.
This commit is contained in:
Joseph Doherty
2026-03-16 20:57:25 -04:00
parent a3bf0c43f3
commit 389f5a0378
97 changed files with 8308 additions and 127 deletions

View File

@@ -0,0 +1,144 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using NSubstitute;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.Commons.Messages.DataConnection;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.DataConnectionLayer.Actors;
namespace ScadaLink.DataConnectionLayer.Tests;
/// <summary>
/// WP-6: Tests for DataConnectionActor Become/Stash state machine.
/// WP-9: Auto-reconnect and bad quality tests.
/// WP-10: Transparent re-subscribe tests.
/// WP-11: Write-back support tests.
/// WP-12: Tag path resolution with retry tests.
/// WP-13: Health reporting tests.
/// WP-14: Subscription lifecycle tests.
/// </summary>
public class DataConnectionActorTests : TestKit
{
private readonly IDataConnection _mockAdapter;
private readonly DataConnectionOptions _options;
public DataConnectionActorTests()
: base(@"akka.loglevel = DEBUG")
{
_mockAdapter = Substitute.For<IDataConnection>();
_options = new DataConnectionOptions
{
ReconnectInterval = TimeSpan.FromMilliseconds(100),
TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200),
WriteTimeout = TimeSpan.FromSeconds(5)
};
}
private IActorRef CreateConnectionActor(string name = "test-conn")
{
return Sys.ActorOf(Props.Create(() =>
new DataConnectionActor(name, _mockAdapter, _options)), name);
}
[Fact]
public void WP6_StartsInConnectingState_AttemptsConnect()
{
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var actor = CreateConnectionActor();
// Give it time to attempt connection
AwaitCondition(() =>
_mockAdapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ConnectAsync"),
TimeSpan.FromSeconds(2));
}
[Fact]
public void WP6_ConnectingState_StashesSubscribeRequests()
{
// Make connect hang so we stay in Connecting
var tcs = new TaskCompletionSource();
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(tcs.Task);
var actor = CreateConnectionActor("stash-test");
// Send subscribe while connecting — should be stashed
actor.Tell(new SubscribeTagsRequest(
"corr1", "inst1", "stash-test", ["tag1"], DateTimeOffset.UtcNow));
// No response yet (stashed)
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
// Complete connection — should unstash and process
_mockAdapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
.Returns("sub-001");
tcs.SetResult();
// Now we should get the response
ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(2));
}
[Fact]
public async Task WP11_ConnectedState_Write_ReturnsResult()
{
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_mockAdapter.WriteAsync("tag1", 42, Arg.Any<CancellationToken>())
.Returns(new WriteResult(true, null));
var actor = CreateConnectionActor("write-test");
// Wait for connected state
AwaitCondition(() =>
_mockAdapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ConnectAsync"),
TimeSpan.FromSeconds(2));
// Small delay for state transition
await Task.Delay(200);
actor.Tell(new WriteTagRequest("corr1", "write-test", "tag1", 42, DateTimeOffset.UtcNow));
var response = ExpectMsg<WriteTagResponse>(TimeSpan.FromSeconds(3));
Assert.True(response.Success);
}
[Fact]
public async Task WP11_Write_Failure_ReturnedSynchronously()
{
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_mockAdapter.WriteAsync("tag1", 42, Arg.Any<CancellationToken>())
.Returns(new WriteResult(false, "Device offline"));
var actor = CreateConnectionActor("write-fail-test");
await Task.Delay(300);
actor.Tell(new WriteTagRequest("corr1", "write-fail-test", "tag1", 42, DateTimeOffset.UtcNow));
var response = ExpectMsg<WriteTagResponse>(TimeSpan.FromSeconds(3));
Assert.False(response.Success);
Assert.Equal("Device offline", response.ErrorMessage);
}
[Fact]
public async Task WP13_HealthReport_ReturnsConnectionStatus()
{
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_mockAdapter.Status.Returns(ConnectionHealth.Connected);
var actor = CreateConnectionActor("health-test");
await Task.Delay(300);
actor.Tell(new DataConnectionActor.GetHealthReport());
var report = ExpectMsg<DataConnectionHealthReport>(TimeSpan.FromSeconds(2));
Assert.Equal("health-test", report.ConnectionName);
Assert.Equal(ConnectionHealth.Connected, report.Status);
}
}

View File

@@ -0,0 +1,65 @@
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.DataConnectionLayer.Adapters;
namespace ScadaLink.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_LmxProxy_ReturnsLmxProxyAdapter()
{
var factory = new DataConnectionFactory(NullLoggerFactory.Instance);
var connection = factory.Create("LmxProxy", new Dictionary<string, string>());
Assert.IsType<LmxProxyDataConnection>(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);
}
}

View File

@@ -0,0 +1,77 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using NSubstitute;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.Commons.Messages.DataConnection;
using ScadaLink.DataConnectionLayer.Actors;
namespace ScadaLink.DataConnectionLayer.Tests;
/// <summary>
/// WP-34: Tests for DataConnectionManagerActor routing and lifecycle.
/// </summary>
public class DataConnectionManagerActorTests : TestKit
{
private readonly IDataConnectionFactory _mockFactory;
private readonly DataConnectionOptions _options;
public DataConnectionManagerActorTests()
: base(@"akka.loglevel = DEBUG")
{
_mockFactory = Substitute.For<IDataConnectionFactory>();
_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)));
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)));
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 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)));
manager.Tell(new CreateConnectionCommand(
"conn1", "OpcUa", new Dictionary<string, string>()));
// Factory should have been called
AwaitCondition(() =>
_mockFactory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
TimeSpan.FromSeconds(2));
}
}

View File

@@ -0,0 +1,126 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.DataConnectionLayer.Adapters;
namespace ScadaLink.DataConnectionLayer.Tests;
/// <summary>
/// WP-8: Tests for LmxProxy adapter.
/// </summary>
public class LmxProxyDataConnectionTests
{
private readonly ILmxProxyClient _mockClient;
private readonly ILmxProxyClientFactory _mockFactory;
private readonly LmxProxyDataConnection _adapter;
public LmxProxyDataConnectionTests()
{
_mockClient = Substitute.For<ILmxProxyClient>();
_mockFactory = Substitute.For<ILmxProxyClientFactory>();
_mockFactory.Create().Returns(_mockClient);
_adapter = new LmxProxyDataConnection(_mockFactory, NullLogger<LmxProxyDataConnection>.Instance);
}
[Fact]
public async Task Connect_OpensSessionWithHostAndPort()
{
_mockClient.OpenSessionAsync("myhost", 5001, Arg.Any<CancellationToken>())
.Returns("session-123");
_mockClient.IsConnected.Returns(true);
await _adapter.ConnectAsync(new Dictionary<string, string>
{
["Host"] = "myhost",
["Port"] = "5001"
});
Assert.Equal(ConnectionHealth.Connected, _adapter.Status);
await _mockClient.Received(1).OpenSessionAsync("myhost", 5001, Arg.Any<CancellationToken>());
}
[Fact]
public async Task Disconnect_ClosesSession()
{
_mockClient.IsConnected.Returns(true);
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("session-123");
await _adapter.ConnectAsync(new Dictionary<string, string>());
await _adapter.DisconnectAsync();
Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
await _mockClient.Received(1).CloseSessionAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Write_Success_ReturnsGoodResult()
{
_mockClient.IsConnected.Returns(true);
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("session-123");
_mockClient.WriteTagAsync("Tag1", 42, Arg.Any<CancellationToken>())
.Returns(true);
await _adapter.ConnectAsync(new Dictionary<string, string>());
var result = await _adapter.WriteAsync("Tag1", 42);
Assert.True(result.Success);
}
[Fact]
public async Task Write_Failure_ReturnsError()
{
_mockClient.IsConnected.Returns(true);
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("session-123");
_mockClient.WriteTagAsync("Tag1", 42, Arg.Any<CancellationToken>())
.Returns(false);
await _adapter.ConnectAsync(new Dictionary<string, string>());
var result = await _adapter.WriteAsync("Tag1", 42);
Assert.False(result.Success);
Assert.Equal("LmxProxy write failed", result.ErrorMessage);
}
[Fact]
public async Task Read_Good_ReturnsValue()
{
_mockClient.IsConnected.Returns(true);
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("session-123");
_mockClient.ReadTagAsync("Tag1", Arg.Any<CancellationToken>())
.Returns((42.5, DateTime.UtcNow, true));
await _adapter.ConnectAsync(new Dictionary<string, string>());
var result = await _adapter.ReadAsync("Tag1");
Assert.True(result.Success);
Assert.Equal(42.5, result.Value!.Value);
}
[Fact]
public async Task Read_Bad_ReturnsFailure()
{
_mockClient.IsConnected.Returns(true);
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("session-123");
_mockClient.ReadTagAsync("Tag1", Arg.Any<CancellationToken>())
.Returns((null, DateTime.UtcNow, false));
await _adapter.ConnectAsync(new Dictionary<string, string>());
var result = await _adapter.ReadAsync("Tag1");
Assert.False(result.Success);
}
[Fact]
public async Task NotConnected_ThrowsOnOperations()
{
_mockClient.IsConnected.Returns(false);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_adapter.ReadAsync("tag1"));
}
}

View File

@@ -0,0 +1,152 @@
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<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);
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -9,8 +9,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka.TestKit.Xunit2" Version="1.5.62" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
@@ -21,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="../../src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj" />
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -1,10 +0,0 @@
namespace ScadaLink.DataConnectionLayer.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}