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:
260
tests/ScadaLink.SiteRuntime.Tests/Actors/AlarmActorTests.cs
Normal file
260
tests/ScadaLink.SiteRuntime.Tests/Actors/AlarmActorTests.cs
Normal file
@@ -0,0 +1,260 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.SiteRuntime.Actors;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// WP-16: Alarm Actor tests — value match, range violation, rate of change.
|
||||
/// WP-21: Alarm on-trigger call direction tests.
|
||||
/// </summary>
|
||||
public class AlarmActorTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SharedScriptLibrary _sharedLibrary;
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
|
||||
public AlarmActorTests()
|
||||
{
|
||||
_compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
_sharedLibrary = new SharedScriptLibrary(
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
_options = new SiteRuntimeOptions();
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_ActivatesOnMatch()
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Send value that matches
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
// Instance Actor should receive AlarmStateChanged
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
Assert.Equal("HighTemp", msg.AlarmName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_ClearsOnNonMatch()
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Activate
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||
var activateMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, activateMsg.State);
|
||||
|
||||
// Clear
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Normal", "Good", DateTimeOffset.UtcNow));
|
||||
var clearMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clearMsg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RangeViolation_ActivatesOutsideRange()
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "TempRange",
|
||||
TriggerType = "RangeViolation",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}",
|
||||
PriorityLevel = 2
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"TempRange", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Value within range -- no alarm
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "50", "Good", DateTimeOffset.UtcNow));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// Value outside range -- alarm activates
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RangeViolation_ClearsWhenBackInRange()
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "TempRange",
|
||||
TriggerType = "RangeViolation",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}",
|
||||
PriorityLevel = 2
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"TempRange", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Activate
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Clear
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "75", "Good", DateTimeOffset.UtcNow));
|
||||
var clearMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clearMsg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_IgnoresUnmonitoredAttributes()
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "TempAlarm",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"matchValue\":\"100\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Send change for a different attribute
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Pressure", "Pressure", "100", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_DoesNotReTrigger_WhenAlreadyActive()
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "TempAlarm",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// First trigger
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Second trigger with same value -- should NOT re-trigger
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_StartsNormal_OnRestart()
|
||||
{
|
||||
// Per design: on restart, alarm starts normal, re-evaluates from incoming values
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "RestartAlarm",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"RestartAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// A "Good" value should not trigger since alarm starts Normal
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Good", "Good", DateTimeOffset.UtcNow));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_NoClearScript_OnDeactivation()
|
||||
{
|
||||
// WP-16: On clear, NO script is executed. Only on activate.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "ClearTest",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"ClearTest", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, // no on-trigger script
|
||||
_sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Activate
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Clear -- should send state change but no script execution
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Good", "Good", DateTimeOffset.UtcNow));
|
||||
var clearMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clearMsg.State);
|
||||
|
||||
// No additional messages (no script execution side effects)
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.SiteRuntime.Actors;
|
||||
using ScadaLink.SiteRuntime.Persistence;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Tests.Actors;
|
||||
@@ -19,6 +20,8 @@ namespace ScadaLink.SiteRuntime.Tests.Actors;
|
||||
public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly string _dbFile;
|
||||
|
||||
public DeploymentManagerActorTests()
|
||||
@@ -28,6 +31,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
$"Data Source={_dbFile}",
|
||||
NullLogger<SiteStorageService>.Instance);
|
||||
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||
_compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
_sharedScriptLibrary = new SharedScriptLibrary(
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
@@ -36,6 +43,18 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
try { File.Delete(_dbFile); } catch { /* cleanup */ }
|
||||
}
|
||||
|
||||
private IActorRef CreateDeploymentManager(SiteRuntimeOptions? options = null)
|
||||
{
|
||||
options ??= new SiteRuntimeOptions();
|
||||
return ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null, // no stream manager in tests
|
||||
options,
|
||||
NullLogger<DeploymentManagerActor>.Instance)));
|
||||
}
|
||||
|
||||
private static string MakeConfigJson(string instanceName)
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
@@ -56,14 +75,13 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
await _storage.StoreDeployedConfigAsync("Pump1", MakeConfigJson("Pump1"), "d1", "h1", true);
|
||||
await _storage.StoreDeployedConfigAsync("Pump2", MakeConfigJson("Pump2"), "d2", "h2", true);
|
||||
|
||||
var options = new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 };
|
||||
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||
_storage, options, NullLogger<DeploymentManagerActor>.Instance)));
|
||||
var actor = CreateDeploymentManager(
|
||||
new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 });
|
||||
|
||||
// Allow time for async startup (load configs + create actors)
|
||||
await Task.Delay(2000);
|
||||
|
||||
// Verify by deploying — if actors already exist, we'd get a warning
|
||||
// Verify by deploying -- if actors already exist, we'd get a warning
|
||||
// Instead, verify by checking we can send lifecycle commands
|
||||
actor.Tell(new DisableInstanceCommand("cmd-1", "Pump1", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
|
||||
@@ -77,14 +95,13 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
await _storage.StoreDeployedConfigAsync("Active1", MakeConfigJson("Active1"), "d1", "h1", true);
|
||||
await _storage.StoreDeployedConfigAsync("Disabled1", MakeConfigJson("Disabled1"), "d2", "h2", false);
|
||||
|
||||
var options = new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 };
|
||||
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||
_storage, options, NullLogger<DeploymentManagerActor>.Instance)));
|
||||
var actor = CreateDeploymentManager(
|
||||
new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 });
|
||||
|
||||
await Task.Delay(2000);
|
||||
|
||||
// The disabled instance should NOT have an actor running
|
||||
// Try to disable it — it should succeed (no actor to stop, but SQLite update works)
|
||||
// Try to disable it -- it should succeed (no actor to stop, but SQLite update works)
|
||||
actor.Tell(new DisableInstanceCommand("cmd-2", "Disabled1", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Success);
|
||||
@@ -101,9 +118,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
// Use a small batch size to force multiple batches
|
||||
var options = new SiteRuntimeOptions { StartupBatchSize = 2, StartupBatchDelayMs = 50 };
|
||||
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||
_storage, options, NullLogger<DeploymentManagerActor>.Instance)));
|
||||
var actor = CreateDeploymentManager(
|
||||
new SiteRuntimeOptions { StartupBatchSize = 2, StartupBatchDelayMs = 50 });
|
||||
|
||||
// Wait for all batches to complete (3 batches with 50ms delay = ~150ms + processing)
|
||||
await Task.Delay(3000);
|
||||
@@ -120,9 +136,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
[Fact]
|
||||
public async Task DeploymentManager_Deploy_CreatesNewInstance()
|
||||
{
|
||||
var options = new SiteRuntimeOptions();
|
||||
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||
_storage, options, NullLogger<DeploymentManagerActor>.Instance)));
|
||||
var actor = CreateDeploymentManager();
|
||||
|
||||
await Task.Delay(500); // Wait for empty startup
|
||||
|
||||
@@ -137,9 +151,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
[Fact]
|
||||
public async Task DeploymentManager_Lifecycle_DisableEnableDelete()
|
||||
{
|
||||
var options = new SiteRuntimeOptions();
|
||||
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||
_storage, options, NullLogger<DeploymentManagerActor>.Instance)));
|
||||
var actor = CreateDeploymentManager();
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
@@ -150,7 +162,6 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Wait for the async deploy persistence (PipeTo) to complete
|
||||
// The deploy handler replies immediately but persists asynchronously
|
||||
await Task.Delay(1000);
|
||||
|
||||
// Disable
|
||||
@@ -179,15 +190,9 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
[Fact]
|
||||
public void DeploymentManager_SupervisionStrategy_ResumesOnException()
|
||||
{
|
||||
// Verify the supervision strategy by creating the actor and checking
|
||||
// that it uses OneForOneStrategy
|
||||
var options = new SiteRuntimeOptions();
|
||||
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||
_storage, options, NullLogger<DeploymentManagerActor>.Instance)));
|
||||
var actor = CreateDeploymentManager();
|
||||
|
||||
// The actor exists and is responsive — supervision is configured
|
||||
// The actual Resume behavior is verified implicitly: if an Instance Actor
|
||||
// throws during message handling, it resumes rather than restarting
|
||||
// The actor exists and is responsive -- supervision is configured
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-sup", "SupervisedPump", "sha256:sup",
|
||||
MakeConfigJson("SupervisedPump"), "admin", DateTimeOffset.UtcNow));
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.Commons.Messages.DebugView;
|
||||
using ScadaLink.Commons.Messages.Instance;
|
||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.SiteRuntime.Actors;
|
||||
using ScadaLink.SiteRuntime.Persistence;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for InstanceActor with child Script/Alarm actors (WP-15, WP-16, WP-24, WP-25).
|
||||
/// </summary>
|
||||
public class InstanceActorIntegrationTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly string _dbFile;
|
||||
|
||||
public InstanceActorIntegrationTests()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"instance-int-test-{Guid.NewGuid():N}.db");
|
||||
_storage = new SiteStorageService(
|
||||
$"Data Source={_dbFile}",
|
||||
NullLogger<SiteStorageService>.Instance);
|
||||
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||
_compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
_sharedScriptLibrary = new SharedScriptLibrary(
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
_options = new SiteRuntimeOptions
|
||||
{
|
||||
MaxScriptCallDepth = 10,
|
||||
ScriptExecutionTimeoutSeconds = 30
|
||||
};
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
try { File.Delete(_dbFile); } catch { /* cleanup */ }
|
||||
}
|
||||
|
||||
private IActorRef CreateInstanceWithScripts(
|
||||
string instanceName,
|
||||
IReadOnlyList<ResolvedScript>? scripts = null,
|
||||
IReadOnlyList<ResolvedAlarm>? alarms = null)
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" },
|
||||
new ResolvedAttribute { CanonicalName = "Status", Value = "Running", DataType = "String" }
|
||||
],
|
||||
Scripts = scripts ?? [],
|
||||
Alarms = alarms ?? []
|
||||
};
|
||||
|
||||
return ActorOf(Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null, // no stream manager
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceActor_CreatesScriptActors_FromConfig()
|
||||
{
|
||||
var scripts = new[]
|
||||
{
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "GetValue",
|
||||
Code = "42"
|
||||
}
|
||||
};
|
||||
|
||||
var actor = CreateInstanceWithScripts("Pump1", scripts);
|
||||
|
||||
// Verify script actor is reachable via CallScript
|
||||
actor.Tell(new ScriptCallRequest("GetValue", null, 0, "corr-1"));
|
||||
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(42, result.ReturnValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceActor_ScriptCallRequest_UnknownScript_ReturnsError()
|
||||
{
|
||||
var actor = CreateInstanceWithScripts("Pump1");
|
||||
|
||||
actor.Tell(new ScriptCallRequest("NonExistent", null, 0, "corr-2"));
|
||||
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(5));
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not found", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceActor_WP24_StateMutationsSerializedThroughMailbox()
|
||||
{
|
||||
// WP-24: Instance Actor processes messages sequentially.
|
||||
// Verify that rapid attribute changes don't corrupt state.
|
||||
var actor = CreateInstanceWithScripts("Pump1");
|
||||
|
||||
// Send many rapid set commands
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
$"corr-{i}", "Pump1", "Temperature", $"{i}", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
// Wait for all to process
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
// The last value should be the final one
|
||||
actor.Tell(new GetAttributeRequest(
|
||||
"corr-final", "Pump1", "Temperature", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("49", response.Value?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceActor_WP25_DebugViewSubscribe_ReturnsSnapshot()
|
||||
{
|
||||
var actor = CreateInstanceWithScripts("Pump1");
|
||||
|
||||
// Wait for initialization
|
||||
Thread.Sleep(500);
|
||||
|
||||
actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-1"));
|
||||
var snapshot = ExpectMsg<DebugViewSnapshot>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal("Pump1", snapshot.InstanceUniqueName);
|
||||
Assert.True(snapshot.AttributeValues.Count >= 2); // Temperature + Status
|
||||
Assert.True(snapshot.SnapshotTimestamp > DateTimeOffset.MinValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceActor_WP25_DebugViewSubscriber_ReceivesChanges()
|
||||
{
|
||||
var actor = CreateInstanceWithScripts("Pump1");
|
||||
|
||||
// Subscribe to debug view
|
||||
actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-2"));
|
||||
ExpectMsg<DebugViewSnapshot>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Now change an attribute
|
||||
actor.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "200", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
// The subscriber should receive the change notification
|
||||
var changed = ExpectMsg<AttributeValueChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Temperature", changed.AttributeName);
|
||||
Assert.Equal("200", changed.Value?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceActor_WP25_DebugViewUnsubscribe_StopsNotifications()
|
||||
{
|
||||
var actor = CreateInstanceWithScripts("Pump1");
|
||||
|
||||
// Subscribe
|
||||
actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-3"));
|
||||
ExpectMsg<DebugViewSnapshot>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Unsubscribe
|
||||
actor.Tell(new UnsubscribeDebugViewRequest("Pump1", "debug-3"));
|
||||
|
||||
// Change attribute
|
||||
actor.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "300", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
// Should NOT receive change notification
|
||||
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceActor_CreatesAlarmActors_FromConfig()
|
||||
{
|
||||
var alarms = new[]
|
||||
{
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "RangeViolation",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}",
|
||||
PriorityLevel = 1
|
||||
}
|
||||
};
|
||||
|
||||
var actor = CreateInstanceWithScripts("Pump1", alarms: alarms);
|
||||
|
||||
// Subscribe to debug view to observe alarm state changes
|
||||
actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-alarm"));
|
||||
ExpectMsg<DebugViewSnapshot>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Send value outside range to trigger alarm
|
||||
actor.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
// Should receive the attribute change first (from debug subscription)
|
||||
ExpectMsg<AttributeValueChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Then the alarm state change (forwarded by Instance Actor)
|
||||
var alarmMsg = ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("HighTemp", alarmMsg.AlarmName);
|
||||
Assert.Equal(Commons.Types.Enums.AlarmState.Active, alarmMsg.State);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using ScadaLink.Commons.Messages.Instance;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.SiteRuntime.Actors;
|
||||
using ScadaLink.SiteRuntime.Persistence;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Tests.Actors;
|
||||
@@ -16,6 +17,9 @@ namespace ScadaLink.SiteRuntime.Tests.Actors;
|
||||
public class InstanceActorTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly string _dbFile;
|
||||
|
||||
public InstanceActorTests()
|
||||
@@ -25,6 +29,24 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
$"Data Source={_dbFile}",
|
||||
NullLogger<SiteStorageService>.Instance);
|
||||
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||
_compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
_sharedScriptLibrary = new SharedScriptLibrary(
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
_options = new SiteRuntimeOptions();
|
||||
}
|
||||
|
||||
private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config)
|
||||
{
|
||||
return ActorOf(Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null, // no stream manager in tests
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance)));
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
@@ -46,11 +68,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
]
|
||||
};
|
||||
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"Pump1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
NullLogger<InstanceActor>.Instance)));
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
// Query for an attribute that exists
|
||||
actor.Tell(new GetAttributeRequest(
|
||||
@@ -71,11 +89,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Attributes = []
|
||||
};
|
||||
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"Pump1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
NullLogger<InstanceActor>.Instance)));
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
actor.Tell(new GetAttributeRequest(
|
||||
"corr-2", "Pump1", "NonExistent", DateTimeOffset.UtcNow));
|
||||
@@ -97,13 +111,9 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
]
|
||||
};
|
||||
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"Pump1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
NullLogger<InstanceActor>.Instance)));
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
// Set a static attribute — response comes async via PipeTo
|
||||
// Set a static attribute -- response comes async via PipeTo
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-3", "Pump1", "Temperature", "100.0", DateTimeOffset.UtcNow));
|
||||
|
||||
@@ -131,11 +141,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
]
|
||||
};
|
||||
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"PumpPersist1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
NullLogger<InstanceActor>.Instance)));
|
||||
var actor = CreateInstanceActor("PumpPersist1", config);
|
||||
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-persist", "PumpPersist1", "Temperature", "100.0", DateTimeOffset.UtcNow));
|
||||
@@ -166,11 +172,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
]
|
||||
};
|
||||
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"PumpOverride1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
NullLogger<InstanceActor>.Instance)));
|
||||
var actor = CreateInstanceActor("PumpOverride1", config);
|
||||
|
||||
// Wait for the async override loading to complete (PipeTo)
|
||||
await Task.Delay(1000);
|
||||
@@ -200,7 +202,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
overrides = await _storage.GetStaticOverridesAsync("PumpRedeploy");
|
||||
Assert.Empty(overrides);
|
||||
|
||||
// Create actor with fresh config — should NOT have the override
|
||||
// Create actor with fresh config -- should NOT have the override
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "PumpRedeploy",
|
||||
@@ -210,11 +212,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
]
|
||||
};
|
||||
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"PumpRedeploy",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
NullLogger<InstanceActor>.Instance)));
|
||||
var actor = CreateInstanceActor("PumpRedeploy", config);
|
||||
|
||||
await Task.Delay(1000);
|
||||
|
||||
|
||||
240
tests/ScadaLink.SiteRuntime.Tests/Actors/ScriptActorTests.cs
Normal file
240
tests/ScadaLink.SiteRuntime.Tests/Actors/ScriptActorTests.cs
Normal file
@@ -0,0 +1,240 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.SiteRuntime.Actors;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// WP-15: Script Actor and Script Execution Actor tests.
|
||||
/// WP-20: Recursion limit tests.
|
||||
/// WP-22: Tell vs Ask convention tests.
|
||||
/// WP-32: Script error handling tests.
|
||||
/// </summary>
|
||||
public class ScriptActorTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SharedScriptLibrary _sharedLibrary;
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
|
||||
public ScriptActorTests()
|
||||
{
|
||||
_compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
_sharedLibrary = new SharedScriptLibrary(
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
_options = new SiteRuntimeOptions
|
||||
{
|
||||
MaxScriptCallDepth = 10,
|
||||
ScriptExecutionTimeoutSeconds = 30
|
||||
};
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
}
|
||||
|
||||
private Script<object?> CompileScript(string code)
|
||||
{
|
||||
var scriptOptions = ScriptOptions.Default
|
||||
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
|
||||
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
|
||||
|
||||
var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
|
||||
script.Compile();
|
||||
return script;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_CallScript_ReturnsResult()
|
||||
{
|
||||
var compiled = CompileScript("42");
|
||||
var scriptConfig = new ResolvedScript
|
||||
{
|
||||
CanonicalName = "GetAnswer",
|
||||
Code = "42"
|
||||
};
|
||||
|
||||
var instanceActor = CreateTestProbe();
|
||||
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
|
||||
"GetAnswer",
|
||||
"TestInstance",
|
||||
instanceActor.Ref,
|
||||
compiled,
|
||||
scriptConfig,
|
||||
_sharedLibrary,
|
||||
_options,
|
||||
NullLogger<ScriptActor>.Instance)));
|
||||
|
||||
// Ask pattern (WP-22) for CallScript
|
||||
scriptActor.Tell(new ScriptCallRequest("GetAnswer", null, 0, "corr-1"));
|
||||
|
||||
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(result.Success, $"Script call failed: {result.ErrorMessage}");
|
||||
Assert.Equal(42, result.ReturnValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_CallScript_WithParameters_Works()
|
||||
{
|
||||
var compiled = CompileScript("(int)Parameters[\"x\"] + (int)Parameters[\"y\"]");
|
||||
var scriptConfig = new ResolvedScript
|
||||
{
|
||||
CanonicalName = "Add",
|
||||
Code = "(int)Parameters[\"x\"] + (int)Parameters[\"y\"]"
|
||||
};
|
||||
|
||||
var instanceActor = CreateTestProbe();
|
||||
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
|
||||
"Add",
|
||||
"TestInstance",
|
||||
instanceActor.Ref,
|
||||
compiled,
|
||||
scriptConfig,
|
||||
_sharedLibrary,
|
||||
_options,
|
||||
NullLogger<ScriptActor>.Instance)));
|
||||
|
||||
var parameters = new Dictionary<string, object?> { ["x"] = 3, ["y"] = 4 };
|
||||
scriptActor.Tell(new ScriptCallRequest("Add", parameters, 0, "corr-2"));
|
||||
|
||||
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(7, result.ReturnValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_NullCompiledScript_ReturnsError()
|
||||
{
|
||||
var scriptConfig = new ResolvedScript
|
||||
{
|
||||
CanonicalName = "Broken",
|
||||
Code = ""
|
||||
};
|
||||
|
||||
var instanceActor = CreateTestProbe();
|
||||
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
|
||||
"Broken",
|
||||
"TestInstance",
|
||||
instanceActor.Ref,
|
||||
null, // no compiled script
|
||||
scriptConfig,
|
||||
_sharedLibrary,
|
||||
_options,
|
||||
NullLogger<ScriptActor>.Instance)));
|
||||
|
||||
scriptActor.Tell(new ScriptCallRequest("Broken", null, 0, "corr-3"));
|
||||
|
||||
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(5));
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not compiled", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ValueChangeTrigger_SpawnsExecution()
|
||||
{
|
||||
var compiled = CompileScript("\"triggered\"");
|
||||
var scriptConfig = new ResolvedScript
|
||||
{
|
||||
CanonicalName = "OnChange",
|
||||
Code = "\"triggered\"",
|
||||
TriggerType = "ValueChange",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temperature\"}"
|
||||
};
|
||||
|
||||
var instanceActor = CreateTestProbe();
|
||||
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
|
||||
"OnChange",
|
||||
"TestInstance",
|
||||
instanceActor.Ref,
|
||||
compiled,
|
||||
scriptConfig,
|
||||
_sharedLibrary,
|
||||
_options,
|
||||
NullLogger<ScriptActor>.Instance)));
|
||||
|
||||
// Send an attribute change that matches the trigger
|
||||
scriptActor.Tell(new AttributeValueChanged(
|
||||
"TestInstance", "Temperature", "Temperature", "100.0", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
// The script should execute (we can't easily verify the output since it's fire-and-forget)
|
||||
// But we can verify the actor doesn't crash
|
||||
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_MinTimeBetweenRuns_SkipsIfTooSoon()
|
||||
{
|
||||
var compiled = CompileScript("\"ok\"");
|
||||
var scriptConfig = new ResolvedScript
|
||||
{
|
||||
CanonicalName = "Throttled",
|
||||
Code = "\"ok\"",
|
||||
TriggerType = "ValueChange",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\"}",
|
||||
MinTimeBetweenRuns = TimeSpan.FromMinutes(10) // long minimum
|
||||
};
|
||||
|
||||
var instanceActor = CreateTestProbe();
|
||||
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
|
||||
"Throttled",
|
||||
"TestInstance",
|
||||
instanceActor.Ref,
|
||||
compiled,
|
||||
scriptConfig,
|
||||
_sharedLibrary,
|
||||
_options,
|
||||
NullLogger<ScriptActor>.Instance)));
|
||||
|
||||
// First trigger -- should execute
|
||||
scriptActor.Tell(new AttributeValueChanged(
|
||||
"TestInstance", "Temp", "Temp", "1", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
// Second trigger immediately -- should be skipped due to min time
|
||||
scriptActor.Tell(new AttributeValueChanged(
|
||||
"TestInstance", "Temp", "Temp", "2", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
// No crash expected
|
||||
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_WP32_ScriptFailure_DoesNotDisable()
|
||||
{
|
||||
// Script that throws an exception
|
||||
var compiled = CompileScript("throw new System.Exception(\"boom\")");
|
||||
var scriptConfig = new ResolvedScript
|
||||
{
|
||||
CanonicalName = "Failing",
|
||||
Code = "throw new System.Exception(\"boom\")"
|
||||
};
|
||||
|
||||
var instanceActor = CreateTestProbe();
|
||||
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
|
||||
"Failing",
|
||||
"TestInstance",
|
||||
instanceActor.Ref,
|
||||
compiled,
|
||||
scriptConfig,
|
||||
_sharedLibrary,
|
||||
_options,
|
||||
NullLogger<ScriptActor>.Instance)));
|
||||
|
||||
// First call -- fails
|
||||
scriptActor.Tell(new ScriptCallRequest("Failing", null, 0, "corr-fail-1"));
|
||||
var result1 = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||
Assert.False(result1.Success);
|
||||
|
||||
// Second call -- should still work (script not disabled after failure)
|
||||
scriptActor.Tell(new ScriptCallRequest("Failing", null, 0, "corr-fail-2"));
|
||||
var result2 = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||
Assert.False(result2.Success); // Still fails, but the actor is still alive
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,8 @@ public class NegativeTests
|
||||
checkCmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table'";
|
||||
var tableCount = (long)(await checkCmd.ExecuteScalarAsync())!;
|
||||
|
||||
// Only 2 tables: deployed_configurations and static_attribute_overrides
|
||||
// Only 2 tables in this manually-created schema (tests the constraint that
|
||||
// no template editing tables exist in the manually-created subset)
|
||||
Assert.Equal(2, tableCount);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.SiteRuntime.Persistence;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Tests.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// WP-33: Local Artifact Storage tests — shared scripts, external systems,
|
||||
/// database connections, notification lists.
|
||||
/// </summary>
|
||||
public class ArtifactStorageTests : IAsyncLifetime, IDisposable
|
||||
{
|
||||
private readonly string _dbFile;
|
||||
private SiteStorageService _storage = null!;
|
||||
|
||||
public ArtifactStorageTests()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"artifact-test-{Guid.NewGuid():N}.db");
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_storage = new SiteStorageService(
|
||||
$"Data Source={_dbFile}",
|
||||
NullLogger<SiteStorageService>.Instance);
|
||||
await _storage.InitializeAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { File.Delete(_dbFile); } catch { /* cleanup */ }
|
||||
}
|
||||
|
||||
// ── Shared Script Storage ──
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSharedScript_RoundTrips()
|
||||
{
|
||||
await _storage.StoreSharedScriptAsync("CalcAvg", "return 42;", "{}", "int");
|
||||
|
||||
var scripts = await _storage.GetAllSharedScriptsAsync();
|
||||
Assert.Single(scripts);
|
||||
Assert.Equal("CalcAvg", scripts[0].Name);
|
||||
Assert.Equal("return 42;", scripts[0].Code);
|
||||
Assert.Equal("{}", scripts[0].ParameterDefinitions);
|
||||
Assert.Equal("int", scripts[0].ReturnDefinition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSharedScript_Upserts_OnConflict()
|
||||
{
|
||||
await _storage.StoreSharedScriptAsync("CalcAvg", "return 1;", null, null);
|
||||
await _storage.StoreSharedScriptAsync("CalcAvg", "return 2;", "{\"x\":\"int\"}", "int");
|
||||
|
||||
var scripts = await _storage.GetAllSharedScriptsAsync();
|
||||
Assert.Single(scripts);
|
||||
Assert.Equal("return 2;", scripts[0].Code);
|
||||
Assert.Equal("{\"x\":\"int\"}", scripts[0].ParameterDefinitions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSharedScript_MultipleScripts()
|
||||
{
|
||||
await _storage.StoreSharedScriptAsync("Script1", "1", null, null);
|
||||
await _storage.StoreSharedScriptAsync("Script2", "2", null, null);
|
||||
await _storage.StoreSharedScriptAsync("Script3", "3", null, null);
|
||||
|
||||
var scripts = await _storage.GetAllSharedScriptsAsync();
|
||||
Assert.Equal(3, scripts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSharedScript_NullableFields()
|
||||
{
|
||||
await _storage.StoreSharedScriptAsync("Simple", "42", null, null);
|
||||
|
||||
var scripts = await _storage.GetAllSharedScriptsAsync();
|
||||
Assert.Single(scripts);
|
||||
Assert.Null(scripts[0].ParameterDefinitions);
|
||||
Assert.Null(scripts[0].ReturnDefinition);
|
||||
}
|
||||
|
||||
// ── External System Storage ──
|
||||
|
||||
[Fact]
|
||||
public async Task StoreExternalSystem_DoesNotThrow()
|
||||
{
|
||||
await _storage.StoreExternalSystemAsync(
|
||||
"WeatherAPI", "https://api.weather.com",
|
||||
"ApiKey", "{\"key\":\"abc\"}", "{\"getForecast\":{}}");
|
||||
|
||||
// No exception = success. Query verification would need a Get method.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreExternalSystem_Upserts()
|
||||
{
|
||||
await _storage.StoreExternalSystemAsync("API1", "https://v1", "Basic", null, null);
|
||||
await _storage.StoreExternalSystemAsync("API1", "https://v2", "ApiKey", "{}", null);
|
||||
|
||||
// Upsert should not throw
|
||||
}
|
||||
|
||||
// ── Database Connection Storage ──
|
||||
|
||||
[Fact]
|
||||
public async Task StoreDatabaseConnection_DoesNotThrow()
|
||||
{
|
||||
await _storage.StoreDatabaseConnectionAsync(
|
||||
"MainDB", "Server=localhost;Database=main", 3, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreDatabaseConnection_Upserts()
|
||||
{
|
||||
await _storage.StoreDatabaseConnectionAsync(
|
||||
"DB1", "Server=old", 3, TimeSpan.FromSeconds(1));
|
||||
await _storage.StoreDatabaseConnectionAsync(
|
||||
"DB1", "Server=new", 5, TimeSpan.FromSeconds(2));
|
||||
|
||||
// Upsert should not throw
|
||||
}
|
||||
|
||||
// ── Notification List Storage ──
|
||||
|
||||
[Fact]
|
||||
public async Task StoreNotificationList_DoesNotThrow()
|
||||
{
|
||||
await _storage.StoreNotificationListAsync(
|
||||
"Ops Team", ["ops@example.com", "admin@example.com"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreNotificationList_Upserts()
|
||||
{
|
||||
await _storage.StoreNotificationListAsync("Team1", ["a@b.com"]);
|
||||
await _storage.StoreNotificationListAsync("Team1", ["x@y.com", "z@w.com"]);
|
||||
|
||||
// Upsert should not throw
|
||||
}
|
||||
|
||||
// ── Schema includes all WP-33 tables ──
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_CreatesAllArtifactTables()
|
||||
{
|
||||
// The initialize already ran. Verify by storing to each table.
|
||||
await _storage.StoreSharedScriptAsync("s", "code", null, null);
|
||||
await _storage.StoreExternalSystemAsync("e", "url", "None", null, null);
|
||||
await _storage.StoreDatabaseConnectionAsync("d", "connstr", 1, TimeSpan.Zero);
|
||||
await _storage.StoreNotificationListAsync("n", ["email@test.com"]);
|
||||
|
||||
// All succeeded without exceptions = tables exist
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.Streams.TestKit" Version="1.5.62" />
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" Version="1.5.62" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.3.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-19: Script Trust Model tests — validates forbidden API detection and compilation.
|
||||
/// </summary>
|
||||
public class ScriptCompilationServiceTests
|
||||
{
|
||||
private readonly ScriptCompilationService _service;
|
||||
|
||||
public ScriptCompilationServiceTests()
|
||||
{
|
||||
_service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_ValidScript_Succeeds()
|
||||
{
|
||||
var result = _service.Compile("test", "1 + 1");
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(result.CompiledScript);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_InvalidSyntax_ReturnsErrors()
|
||||
{
|
||||
var result = _service.Compile("bad", "this is not valid C# {{{");
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTrustModel_SystemIO_Forbidden()
|
||||
{
|
||||
var violations = _service.ValidateTrustModel("System.IO.File.ReadAllText(\"test\")");
|
||||
Assert.NotEmpty(violations);
|
||||
Assert.Contains(violations, v => v.Contains("System.IO"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTrustModel_Process_Forbidden()
|
||||
{
|
||||
var violations = _service.ValidateTrustModel(
|
||||
"System.Diagnostics.Process.Start(\"cmd\")");
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTrustModel_Reflection_Forbidden()
|
||||
{
|
||||
var violations = _service.ValidateTrustModel(
|
||||
"typeof(string).GetType().GetMethods(System.Reflection.BindingFlags.Public)");
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTrustModel_Sockets_Forbidden()
|
||||
{
|
||||
var violations = _service.ValidateTrustModel(
|
||||
"new System.Net.Sockets.TcpClient()");
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTrustModel_HttpClient_Forbidden()
|
||||
{
|
||||
var violations = _service.ValidateTrustModel(
|
||||
"new System.Net.Http.HttpClient()");
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTrustModel_AsyncAwait_Allowed()
|
||||
{
|
||||
// System.Threading.Tasks should be allowed (async/await support)
|
||||
var violations = _service.ValidateTrustModel(
|
||||
"await System.Threading.Tasks.Task.Delay(100)");
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTrustModel_CancellationToken_Allowed()
|
||||
{
|
||||
var violations = _service.ValidateTrustModel(
|
||||
"System.Threading.CancellationToken.None");
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTrustModel_CleanCode_NoViolations()
|
||||
{
|
||||
var code = @"
|
||||
var x = 1 + 2;
|
||||
var list = new List<int> { 1, 2, 3 };
|
||||
var sum = list.Sum();
|
||||
sum";
|
||||
var violations = _service.ValidateTrustModel(code);
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_ForbiddenApi_FailsValidation()
|
||||
{
|
||||
var result = _service.Compile("evil", "System.IO.File.Delete(\"/tmp/test\")");
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-17: Shared Script Library tests — compile, register, execute inline.
|
||||
/// </summary>
|
||||
public class SharedScriptLibraryTests
|
||||
{
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _library;
|
||||
|
||||
public SharedScriptLibraryTests()
|
||||
{
|
||||
_compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
_library = new SharedScriptLibrary(
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompileAndRegister_ValidScript_Succeeds()
|
||||
{
|
||||
var result = _library.CompileAndRegister("add", "1 + 2");
|
||||
Assert.True(result);
|
||||
Assert.True(_library.Contains("add"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompileAndRegister_InvalidScript_ReturnsFalse()
|
||||
{
|
||||
var result = _library.CompileAndRegister("bad", "this is not valid {{{");
|
||||
Assert.False(result);
|
||||
Assert.False(_library.Contains("bad"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompileAndRegister_ForbiddenApi_ReturnsFalse()
|
||||
{
|
||||
var result = _library.CompileAndRegister("evil", "System.IO.File.Delete(\"/tmp\")");
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompileAndRegister_Replaces_ExistingScript()
|
||||
{
|
||||
_library.CompileAndRegister("calc", "1 + 1");
|
||||
_library.CompileAndRegister("calc", "2 + 2");
|
||||
|
||||
Assert.True(_library.Contains("calc"));
|
||||
// Should have only one entry
|
||||
Assert.Equal(1, _library.GetRegisteredScriptNames().Count(n => n == "calc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_RegisteredScript_ReturnsTrue()
|
||||
{
|
||||
_library.CompileAndRegister("temp", "42");
|
||||
Assert.True(_library.Remove("temp"));
|
||||
Assert.False(_library.Contains("temp"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_NonexistentScript_ReturnsFalse()
|
||||
{
|
||||
Assert.False(_library.Remove("nonexistent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRegisteredScriptNames_ReturnsAllNames()
|
||||
{
|
||||
_library.CompileAndRegister("a", "1");
|
||||
_library.CompileAndRegister("b", "2");
|
||||
_library.CompileAndRegister("c", "3");
|
||||
|
||||
var names = _library.GetRegisteredScriptNames();
|
||||
Assert.Equal(3, names.Count);
|
||||
Assert.Contains("a", names);
|
||||
Assert.Contains("b", names);
|
||||
Assert.Contains("c", names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_NonexistentScript_Throws()
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _library.ExecuteAsync("missing", null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.SiteRuntime.Streaming;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Tests.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// WP-23: Site-Wide Akka Stream tests.
|
||||
/// WP-25: Debug View Backend tests (subscribe/unsubscribe).
|
||||
/// </summary>
|
||||
public class SiteStreamManagerTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStreamManager _streamManager;
|
||||
|
||||
public SiteStreamManagerTests()
|
||||
{
|
||||
var options = new SiteRuntimeOptions { StreamBufferSize = 100 };
|
||||
_streamManager = new SiteStreamManager(
|
||||
Sys, options, NullLogger<SiteStreamManager>.Instance);
|
||||
_streamManager.Initialize();
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_CreatesSubscription()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var id = _streamManager.Subscribe("Pump1", probe.Ref);
|
||||
|
||||
Assert.NotNull(id);
|
||||
Assert.Equal(1, _streamManager.SubscriptionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unsubscribe_RemovesSubscription()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var id = _streamManager.Subscribe("Pump1", probe.Ref);
|
||||
|
||||
Assert.True(_streamManager.Unsubscribe(id));
|
||||
Assert.Equal(0, _streamManager.SubscriptionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unsubscribe_InvalidId_ReturnsFalse()
|
||||
{
|
||||
Assert.False(_streamManager.Unsubscribe("nonexistent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishAttributeValueChanged_ForwardsToSubscriber()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
_streamManager.Subscribe("Pump1", probe.Ref);
|
||||
|
||||
var changed = new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "100", "Good", DateTimeOffset.UtcNow);
|
||||
_streamManager.PublishAttributeValueChanged(changed);
|
||||
|
||||
var received = probe.ExpectMsg<AttributeValueChanged>(TimeSpan.FromSeconds(3));
|
||||
Assert.Equal("Pump1", received.InstanceUniqueName);
|
||||
Assert.Equal("Temperature", received.AttributeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishAlarmStateChanged_ForwardsToSubscriber()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
_streamManager.Subscribe("Pump1", probe.Ref);
|
||||
|
||||
var changed = new AlarmStateChanged(
|
||||
"Pump1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow);
|
||||
_streamManager.PublishAlarmStateChanged(changed);
|
||||
|
||||
var received = probe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(3));
|
||||
Assert.Equal("Pump1", received.InstanceUniqueName);
|
||||
Assert.Equal(AlarmState.Active, received.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishAttributeValueChanged_FiltersbyInstance()
|
||||
{
|
||||
var probe1 = CreateTestProbe();
|
||||
var probe2 = CreateTestProbe();
|
||||
_streamManager.Subscribe("Pump1", probe1.Ref);
|
||||
_streamManager.Subscribe("Pump2", probe2.Ref);
|
||||
|
||||
var changed = new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "100", "Good", DateTimeOffset.UtcNow);
|
||||
_streamManager.PublishAttributeValueChanged(changed);
|
||||
|
||||
// Pump1 subscriber should receive
|
||||
probe1.ExpectMsg<AttributeValueChanged>(TimeSpan.FromSeconds(3));
|
||||
|
||||
// Pump2 subscriber should NOT receive
|
||||
probe2.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveSubscriber_RemovesAllSubscriptionsForActor()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
_streamManager.Subscribe("Pump1", probe.Ref);
|
||||
_streamManager.Subscribe("Pump2", probe.Ref);
|
||||
|
||||
Assert.Equal(2, _streamManager.SubscriptionCount);
|
||||
|
||||
_streamManager.RemoveSubscriber(probe.Ref);
|
||||
Assert.Equal(0, _streamManager.SubscriptionCount);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user