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:
@@ -0,0 +1,875 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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));
|
||||
}
|
||||
|
||||
// ── RateOfChange ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a RateOfChange config JSON with the given threshold (units/sec),
|
||||
/// window (seconds), and direction. Used by the rate-of-change tests.
|
||||
/// </summary>
|
||||
private static string RocConfig(double thresholdPerSecond, double windowSeconds, string direction) =>
|
||||
$"{{\"attributeName\":\"Pressure\",\"thresholdPerSecond\":{thresholdPerSecond.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"windowSeconds\":{windowSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"direction\":\"{direction}\"}}";
|
||||
|
||||
private IActorRef SpawnRocAlarm(string config, TestProbe instanceProbe)
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "RocAlarm",
|
||||
TriggerType = "RateOfChange",
|
||||
TriggerConfiguration = config,
|
||||
PriorityLevel = 3
|
||||
};
|
||||
return ActorOf(Props.Create(() => new AlarmActor(
|
||||
"RocAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
}
|
||||
|
||||
private static AttributeValueChanged PressureSample(double value, DateTimeOffset ts) =>
|
||||
new("Pump1", "Pressure", "Pressure",
|
||||
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"Good", ts);
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidRise()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
// 50 units/sec threshold, 2 sec window
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
// First sample establishes the window baseline; needs ≥2 samples to compute a rate.
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// 100 over 1 sec = 100 units/sec > 50 threshold → activate
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(1)));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidFall()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(100, t0));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// -100 over 1 sec → |rate| = 100 > 50 → activate (Either covers both signs)
|
||||
alarm.Tell(PressureSample(0, t0.AddSeconds(1)));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_DoesNotActivateWhenBelowThreshold()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
// 10 over 1 sec = 10 units/sec < 50 threshold → no alarm
|
||||
alarm.Tell(PressureSample(10, t0.AddSeconds(1)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Rising_IgnoresFallingSpikes()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "rising"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(100, t0));
|
||||
// -100 over 1 sec → would trigger Either, but Rising only fires on positive rate
|
||||
alarm.Tell(PressureSample(0, t0.AddSeconds(1)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Falling_IgnoresRisingSpikes()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(1)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Falling_ActivatesOnFallingRate()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(100, t0));
|
||||
alarm.Tell(PressureSample(0, t0.AddSeconds(1))); // -100/sec, |rate| > threshold, falling
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_SingleSample_DoesNotActivate()
|
||||
{
|
||||
// The evaluator needs at least two samples in the window to compute a rate.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(1000, t0));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_WindowRollsOff_OldSamplesDiscarded()
|
||||
{
|
||||
// 1-second window. Sample at t=0 with value 0 should fall out before the
|
||||
// sample at t=3, so the in-window history is just the two recent samples
|
||||
// (t=2.5, v=99) and (t=3, v=100) → rate = 1 unit / 0.5s = 2/sec — below
|
||||
// the threshold, so no alarm even though the long-term delta is huge.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
alarm.Tell(PressureSample(99, t0.AddSeconds(2.5)));
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(3)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_ClearsWhenRateDropsBack()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
// Spike: activate
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(0.5))); // 200/sec > 50
|
||||
var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, activate.State);
|
||||
|
||||
// Now sample again well past the 1-second window with only a tiny change
|
||||
// → rate falls below threshold → clears.
|
||||
alarm.Tell(PressureSample(101, t0.AddSeconds(3)));
|
||||
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clear.State);
|
||||
}
|
||||
|
||||
// ── Legacy JSON aliases & not-equals prefix ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_LegacyAttributeAndValueKeys_StillFire()
|
||||
{
|
||||
// Old configs used "attribute" / "value" before the canonical names landed.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Legacy",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attribute\":\"Status\",\"value\":\"Critical\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_NotEqualsPrefix_FiresWhenValueDiffers()
|
||||
{
|
||||
// matchValue "!=Good" means: alarm when Status is anything other than Good.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Inverted",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"!=Good\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Inverted", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Status=Critical (not "Good") → alarm activates
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||
var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, activate.State);
|
||||
|
||||
// Status=Good → alarm clears
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Good", "Critical", DateTimeOffset.UtcNow));
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clear.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RangeViolation_LegacyLowHighKeys_StillFire()
|
||||
{
|
||||
// Older configs used "low" / "high" instead of the current "min" / "max".
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Legacy",
|
||||
TriggerType = "RangeViolation",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"low\":0,\"high\":100}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Within range → no alarm
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "50", "Good", DateTimeOffset.UtcNow));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Outside range → activate
|
||||
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);
|
||||
}
|
||||
|
||||
// ── HiLo ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Spawns a HiLo alarm with the given JSON config and alarm-level priority fallback.</summary>
|
||||
private IActorRef SpawnHiLoAlarm(string config, TestProbe instanceProbe, int priority = 500)
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "TempAlarm",
|
||||
TriggerType = "HiLo",
|
||||
TriggerConfiguration = config,
|
||||
PriorityLevel = priority
|
||||
};
|
||||
return ActorOf(Props.Create(() => new AlarmActor(
|
||||
"TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
}
|
||||
|
||||
private static AttributeValueChanged TempSample(double value) =>
|
||||
new("Pump1", "Temperature", "Temperature",
|
||||
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"Good", DateTimeOffset.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersHigh_WhenValueCrossesHi()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(50)); // normal band — no emit
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(85)); // crosses Hi but not HiHi
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EscalatesToHighHigh_WhenValueClimbsPastHiHi()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
var first = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.High, first.Level);
|
||||
|
||||
alarm.Tell(TempSample(120)); // crosses HiHi
|
||||
|
||||
var second = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, second.State);
|
||||
Assert.Equal(AlarmLevel.HighHigh, second.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_DescalatesFromHighHighToHigh_WhenValueDrops()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(85)); // back into the Hi band but still alarmed
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_ClearsToNormal_WhenValueReturnsToNormalBand()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(50)); // back to normal
|
||||
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clear.State);
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersLow_WhenValueCrossesLo()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(8));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.Low, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersLowLow_WhenValueCrossesLoLo()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(-5));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.LowLow, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PerSetpointPriority_OverridesAlarmLevelPriority()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
// Alarm-level priority is 500; HiHi explicitly bumps to 900.
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiPriority"":600,""hiHiPriority"":900}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 500);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.High, hi.Level);
|
||||
Assert.Equal(600, hi.Priority);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, hiHi.Level);
|
||||
Assert.Equal(900, hiHi.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_MissingPerSetpointPriority_FallsBackToAlarmLevel()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 432);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(432, msg.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PartialConfig_OnlyHiHiSet_NoEffectInLowRange()
|
||||
{
|
||||
// Only HiHi is configured — values that would have hit a Lo or Hi band
|
||||
// (in a fully-configured alarm) are inside the implicit normal band here.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(-1000));
|
||||
alarm.Tell(TempSample(95));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(110));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_BoundaryValue_AtHiHi_ResolvesToHighHigh()
|
||||
{
|
||||
// When the value exactly equals the boundary, the most-severe matching
|
||||
// band wins. value == HiHi → HighHigh (not High).
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(100));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_StaysAtSameLevel_NoRedundantEmission()
|
||||
{
|
||||
// Two updates that resolve to the same level should produce exactly one
|
||||
// AlarmStateChanged — the second is a no-op.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(90)); // still in the Hi band
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_NoSetpointsConfigured_NeverFires()
|
||||
{
|
||||
// Validation flags this as a warning at design time; runtime behavior
|
||||
// is "evaluates to None forever".
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature""}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(99999));
|
||||
alarm.Tell(TempSample(-99999));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
// ── HiLo hysteresis ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_StaysAtHighHigh_UntilDropsBelowDeadband()
|
||||
{
|
||||
// HiHi=100 with 5-unit deadband. Once at HighHigh, the alarm stays there
|
||||
// until the value drops below 95 — at 96 it should still be HighHigh.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, entered.Level);
|
||||
|
||||
// 96 > 95 (HiHi - deadband) → still HighHigh, no state change emitted
|
||||
alarm.Tell(TempSample(96));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_DropsToHigh_OnlyAfterDeadbandCleared()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// 94 < 95 (HiHi - deadband) → drops to High (still above Hi=80)
|
||||
alarm.Tell(TempSample(94));
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_HiDeadband_PreventsFlapping()
|
||||
{
|
||||
// Hi=80 with 5-unit deadband. After entering Hi, stays Hi until value drops below 75.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiDeadband"":5}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(78)); // 78 > 75 → still High
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(74)); // 74 < 75 → clears to Normal
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_LowSide_Symmetric()
|
||||
{
|
||||
// Lo=10 with 3-unit deadband. After entering Lo, stays Lo until value rises above 13.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10,""loDeadband"":3}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(8));
|
||||
var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.Low, entered.Level);
|
||||
|
||||
alarm.Tell(TempSample(12)); // 12 <= 13 → still Low
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(14)); // 14 > 13 → clears
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PerBandMessage_FlowsToAlarmStateChanged()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiMessage"":""Coolant warm — check tank"",""hiHiMessage"":""Coolant critical — shut down""}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Coolant warm — check tank", hi.Message);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Coolant critical — shut down", hiHi.Message);
|
||||
|
||||
// Clearing back to normal carries an empty message.
|
||||
alarm.Tell(TempSample(50));
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(string.Empty, clear.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_DoesNotDelayEscalation()
|
||||
{
|
||||
// Deadband is only on de-escalation. Escalating up to HighHigh should not be delayed.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":50}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Despite the large deadband, escalation uses the activation threshold (100).
|
||||
alarm.Tell(TempSample(101));
|
||||
var escalated = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, escalated.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
|
||||
{
|
||||
// ParseEvalConfig falls back to a safe default on JSON failure; the actor
|
||||
// should accept messages without throwing and just never trigger.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Bad",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{not valid json",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Bad", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Anything", "Anything", "anything", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DeploymentManagerActor: startup from SQLite, staggered batching,
|
||||
/// lifecycle commands, and supervision strategy.
|
||||
/// </summary>
|
||||
public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly string _dbFile;
|
||||
|
||||
public DeploymentManagerActorTests()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"dm-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);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
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
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "42", DataType = "Int32" }
|
||||
]
|
||||
};
|
||||
return JsonSerializer.Serialize(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a config carrying a single callable (no-trigger) script that
|
||||
/// returns a constant — enough for an inbound <see cref="RouteToCallRequest"/>
|
||||
/// to be routed end-to-end through the Instance/Script/ScriptExecution actors.
|
||||
/// </summary>
|
||||
private static string MakeConfigWithScriptJson(string instanceName, string scriptName)
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "42", DataType = "Int32" }
|
||||
],
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript { CanonicalName = scriptName, Code = "return 7;" }
|
||||
]
|
||||
};
|
||||
return JsonSerializer.Serialize(config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentManager_CreatesInstanceActors_FromStoredConfigs()
|
||||
{
|
||||
// Pre-populate SQLite with deployed configs
|
||||
await _storage.StoreDeployedConfigAsync("Pump1", MakeConfigJson("Pump1"), "d1", "h1", true);
|
||||
await _storage.StoreDeployedConfigAsync("Pump2", MakeConfigJson("Pump2"), "d2", "h2", true);
|
||||
|
||||
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
|
||||
// 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));
|
||||
Assert.True(response.Success);
|
||||
Assert.Equal("Pump1", response.InstanceUniqueName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentManager_SkipsDisabledInstances_OnStartup()
|
||||
{
|
||||
await _storage.StoreDeployedConfigAsync("Active1", MakeConfigJson("Active1"), "d1", "h1", true);
|
||||
await _storage.StoreDeployedConfigAsync("Disabled1", MakeConfigJson("Disabled1"), "d2", "h2", false);
|
||||
|
||||
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)
|
||||
actor.Tell(new DisableInstanceCommand("cmd-2", "Disabled1", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentManager_StaggeredBatchCreation()
|
||||
{
|
||||
// Create more instances than the batch size
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var name = $"Batch{i}";
|
||||
await _storage.StoreDeployedConfigAsync(name, MakeConfigJson(name), $"d{i}", $"h{i}", true);
|
||||
}
|
||||
|
||||
// Use a small batch size to force multiple batches
|
||||
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);
|
||||
|
||||
// Verify all instances are running by disabling them
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
actor.Tell(new DisableInstanceCommand($"cmd-{i}", $"Batch{i}", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Success);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentManager_Deploy_CreatesNewInstance()
|
||||
{
|
||||
var actor = CreateDeploymentManager();
|
||||
|
||||
await Task.Delay(500); // Wait for empty startup
|
||||
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-100", "NewPump", "sha256:xyz", MakeConfigJson("NewPump"), "admin", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(DeploymentStatus.Success, response.Status);
|
||||
Assert.Equal("NewPump", response.InstanceUniqueName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentManager_Lifecycle_DisableEnableDelete()
|
||||
{
|
||||
var actor = CreateDeploymentManager();
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
// Deploy
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-200", "LifecyclePump", "sha256:abc",
|
||||
MakeConfigJson("LifecyclePump"), "admin", DateTimeOffset.UtcNow));
|
||||
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Wait for the async deploy persistence (PipeTo) to complete
|
||||
await Task.Delay(1000);
|
||||
|
||||
// Disable
|
||||
actor.Tell(new DisableInstanceCommand("cmd-d1", "LifecyclePump", DateTimeOffset.UtcNow));
|
||||
var disableResp = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(disableResp.Success);
|
||||
|
||||
// Verify disabled in storage
|
||||
await Task.Delay(500);
|
||||
var configs = await _storage.GetAllDeployedConfigsAsync();
|
||||
var pump = configs.FirstOrDefault(c => c.InstanceUniqueName == "LifecyclePump");
|
||||
Assert.NotNull(pump);
|
||||
Assert.False(pump.IsEnabled);
|
||||
|
||||
// Delete
|
||||
actor.Tell(new DeleteInstanceCommand("cmd-del1", "LifecyclePump", DateTimeOffset.UtcNow));
|
||||
var deleteResp = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(deleteResp.Success);
|
||||
|
||||
// Verify removed from storage
|
||||
await Task.Delay(500);
|
||||
configs = await _storage.GetAllDeployedConfigsAsync();
|
||||
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "LifecyclePump");
|
||||
}
|
||||
|
||||
// ── DeploymentManager-006: query-the-site-before-redeploy ──
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentStateQuery_DeployedInstance_ReturnsAppliedIdentity()
|
||||
{
|
||||
// A deployed instance must report its currently-applied deployment ID
|
||||
// and revision hash so central can reconcile before a re-deploy.
|
||||
await _storage.StoreDeployedConfigAsync(
|
||||
"QueriedPump", MakeConfigJson("QueriedPump"), "dep-applied", "sha256:applied", true);
|
||||
|
||||
var actor = CreateDeploymentManager();
|
||||
await Task.Delay(2000); // allow startup to load configs
|
||||
|
||||
actor.Tell(new DeploymentStateQueryRequest("corr-q1", "QueriedPump", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<DeploymentStateQueryResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("corr-q1", response.CorrelationId);
|
||||
Assert.Equal("QueriedPump", response.InstanceUniqueName);
|
||||
Assert.True(response.IsDeployed);
|
||||
Assert.Equal("dep-applied", response.AppliedDeploymentId);
|
||||
Assert.Equal("sha256:applied", response.AppliedRevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentStateQuery_UnknownInstance_ReturnsNotDeployed()
|
||||
{
|
||||
// An instance the site has never received a deployment for must report
|
||||
// IsDeployed=false with null applied identity.
|
||||
var actor = CreateDeploymentManager();
|
||||
await Task.Delay(500);
|
||||
|
||||
actor.Tell(new DeploymentStateQueryRequest("corr-q2", "NeverDeployed", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<DeploymentStateQueryResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("corr-q2", response.CorrelationId);
|
||||
Assert.False(response.IsDeployed);
|
||||
Assert.Null(response.AppliedDeploymentId);
|
||||
Assert.Null(response.AppliedRevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeploymentManager_SupervisionStrategy_ResumesOnException()
|
||||
{
|
||||
var actor = CreateDeploymentManager();
|
||||
|
||||
// The actor exists and is responsive -- supervision is configured
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-sup", "SupervisedPump", "sha256:sup",
|
||||
MakeConfigJson("SupervisedPump"), "admin", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(DeploymentStatus.Success, response.Status);
|
||||
}
|
||||
|
||||
// ── Audit Log #23 (ParentExecutionId, Task 4): inbound-API routing ──
|
||||
|
||||
[Fact]
|
||||
public async Task RouteInboundApiCall_WithParentExecutionId_RoutesToScriptSuccessfully()
|
||||
{
|
||||
// A RouteToCallRequest carrying a ParentExecutionId (the inbound
|
||||
// request's ExecutionId) must be mapped to a ScriptCallRequest and
|
||||
// routed end-to-end through the Instance/Script/ScriptExecution actors.
|
||||
// The additive ParentExecutionId field must not break that routing.
|
||||
var actor = CreateDeploymentManager();
|
||||
await Task.Delay(500); // empty startup
|
||||
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-route", "RoutedPump", "sha256:route",
|
||||
MakeConfigWithScriptJson("RoutedPump", "DoWork"), "admin", DateTimeOffset.UtcNow));
|
||||
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(1000); // let the InstanceActor + ScriptActor spin up
|
||||
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
actor.Tell(new RouteToCallRequest(
|
||||
"route-corr-1", "RoutedPump", "DoWork",
|
||||
Parameters: null, DateTimeOffset.UtcNow, ParentExecutionId: parentExecutionId));
|
||||
|
||||
var response = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal("route-corr-1", response.CorrelationId);
|
||||
Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}");
|
||||
Assert.Equal(7, Convert.ToInt32(response.ReturnValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RouteInboundApiCall_WithoutParentExecutionId_StillRoutes()
|
||||
{
|
||||
// A routed call with no ParentExecutionId (e.g. the Central UI sandbox)
|
||||
// is the additive-default path — it must route exactly as before.
|
||||
var actor = CreateDeploymentManager();
|
||||
await Task.Delay(500);
|
||||
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-route2", "RoutedPump2", "sha256:route2",
|
||||
MakeConfigWithScriptJson("RoutedPump2", "DoWork"), "admin", DateTimeOffset.UtcNow));
|
||||
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(1000);
|
||||
|
||||
// No ParentExecutionId argument — exercises the additive `= null` default.
|
||||
actor.Tell(new RouteToCallRequest(
|
||||
"route-corr-2", "RoutedPump2", "DoWork",
|
||||
Parameters: null, DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal("route-corr-2", response.CorrelationId);
|
||||
Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for SiteRuntime-015 — <see cref="DeploymentManagerActor"/> must
|
||||
/// reuse a single, injected <see cref="ILoggerFactory"/> for every Instance Actor it
|
||||
/// creates rather than newing (and leaking) a fresh <see cref="LoggerFactory"/> per
|
||||
/// instance.
|
||||
/// </summary>
|
||||
public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly string _dbFile;
|
||||
|
||||
public DeploymentManagerLoggerFactoryTests()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"dm-loggerfactory-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);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
try { File.Delete(_dbFile); } catch { /* cleanup */ }
|
||||
}
|
||||
|
||||
private static string MakeConfigJson(string instanceName)
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
|
||||
]
|
||||
};
|
||||
return JsonSerializer.Serialize(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts <see cref="ILoggerFactory.CreateLogger"/> calls and records whether
|
||||
/// the factory was disposed. A passing test proves the single injected factory
|
||||
/// is the one used for every Instance Actor.
|
||||
/// </summary>
|
||||
private sealed class CountingLoggerFactory : ILoggerFactory
|
||||
{
|
||||
public int CreateLoggerCalls;
|
||||
public bool Disposed;
|
||||
|
||||
public ILogger CreateLogger(string categoryName)
|
||||
{
|
||||
Interlocked.Increment(ref CreateLoggerCalls);
|
||||
return NullLogger.Instance;
|
||||
}
|
||||
|
||||
public void AddProvider(ILoggerProvider provider) { }
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateInstanceActor_ReusesInjectedLoggerFactory_ForEveryInstance()
|
||||
{
|
||||
// Pre-populate several enabled instances so startup creates multiple
|
||||
// Instance Actors.
|
||||
const int instanceCount = 6;
|
||||
for (int i = 0; i < instanceCount; i++)
|
||||
{
|
||||
var name = $"Inst{i}";
|
||||
await _storage.StoreDeployedConfigAsync(name, MakeConfigJson(name), $"d{i}", $"h{i}", true);
|
||||
}
|
||||
|
||||
var loggerFactory = new CountingLoggerFactory();
|
||||
|
||||
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 5 },
|
||||
NullLogger<DeploymentManagerActor>.Instance,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
loggerFactory)));
|
||||
|
||||
// Allow async startup (load configs + staggered creation).
|
||||
await Task.Delay(2000);
|
||||
|
||||
// Every Instance Actor logger must come from the single injected factory.
|
||||
// Before the fix, each CreateInstanceActor allocated its own LoggerFactory,
|
||||
// so the injected factory would never be touched (CreateLoggerCalls == 0).
|
||||
Assert.Equal(instanceCount, loggerFactory.CreateLoggerCalls);
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the Medium-severity DeploymentManagerActor findings:
|
||||
/// SiteRuntime-005 (Success reported before persistence completes) and
|
||||
/// SiteRuntime-008 (blocking shared-script load on the actor thread).
|
||||
/// </summary>
|
||||
public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly string _dbFile;
|
||||
|
||||
public DeploymentManagerMediumFindingsTests()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"dm-medium-test-{Guid.NewGuid():N}.db");
|
||||
_compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
_sharedScriptLibrary = new SharedScriptLibrary(
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
try { File.Delete(_dbFile); } catch { /* cleanup */ }
|
||||
}
|
||||
|
||||
private SiteStorageService NewStorage(string connectionString)
|
||||
=> new(connectionString, NullLogger<SiteStorageService>.Instance);
|
||||
|
||||
private IActorRef CreateDeploymentManager(SiteStorageService storage, IActorRef? dclManager = null)
|
||||
{
|
||||
return ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||
storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
new SiteRuntimeOptions(),
|
||||
NullLogger<DeploymentManagerActor>.Instance,
|
||||
dclManager,
|
||||
null,
|
||||
null,
|
||||
null)));
|
||||
}
|
||||
|
||||
private static string MakeConfigJsonWithConnection(
|
||||
string instanceName, string endpoint, int failoverRetryCount)
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
|
||||
],
|
||||
Connections = new Dictionary<string, ConnectionConfig>
|
||||
{
|
||||
["Conn1"] = new ConnectionConfig
|
||||
{
|
||||
Protocol = "Custom",
|
||||
ConfigurationJson = $"{{\"endpoint\":\"{endpoint}\"}}",
|
||||
FailoverRetryCount = failoverRetryCount
|
||||
}
|
||||
}
|
||||
};
|
||||
return JsonSerializer.Serialize(config);
|
||||
}
|
||||
|
||||
private static string MakeConfigJson(string instanceName)
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
|
||||
]
|
||||
};
|
||||
return JsonSerializer.Serialize(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-005: when SQLite persistence of the deployed config fails, the
|
||||
/// Deployment Manager must report <see cref="DeploymentStatus.Failed"/> to central,
|
||||
/// not <see cref="DeploymentStatus.Success"/>. Reporting Success on a persistence
|
||||
/// failure silently loses the deployment on the next restart/failover.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Deploy_PersistenceFailure_ReportsFailedNotSuccess()
|
||||
{
|
||||
// A connection string pointing at an unwritable path makes every storage
|
||||
// write throw, so StoreDeployedConfigAsync fails.
|
||||
var badPath = Path.Combine(
|
||||
Path.GetTempPath(), $"no-such-dir-{Guid.NewGuid():N}", "site.db");
|
||||
var storage = NewStorage($"Data Source={badPath}");
|
||||
|
||||
var actor = CreateDeploymentManager(storage);
|
||||
await Task.Delay(500); // empty startup
|
||||
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-fail", "FailPump", "h1", MakeConfigJson("FailPump"), "admin", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal("FailPump", response.InstanceUniqueName);
|
||||
Assert.Equal(DeploymentStatus.Failed, response.Status);
|
||||
Assert.False(string.IsNullOrEmpty(response.ErrorMessage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-005: a successful deployment must still report
|
||||
/// <see cref="DeploymentStatus.Success"/>, and only after the config row is
|
||||
/// committed to SQLite (so a restart re-creates the instance).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Deploy_Success_ReportsSuccessAndPersistsConfig()
|
||||
{
|
||||
var storage = NewStorage($"Data Source={_dbFile}");
|
||||
await storage.InitializeAsync();
|
||||
|
||||
var actor = CreateDeploymentManager(storage);
|
||||
await Task.Delay(500);
|
||||
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-ok", "OkPump", "h1", MakeConfigJson("OkPump"), "admin", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal(DeploymentStatus.Success, response.Status);
|
||||
|
||||
// By the time Success is reported, the config must be durable.
|
||||
var configs = await storage.GetAllDeployedConfigsAsync();
|
||||
Assert.Contains(configs, c => c.InstanceUniqueName == "OkPump");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-010: when a redeployment changes a connection's configuration
|
||||
/// (here the failover retry count and endpoint), the Deployment Manager must
|
||||
/// re-issue a <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection.CreateConnectionCommand"/>
|
||||
/// so the DCL adopts the new configuration rather than keeping the stale one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EnsureDclConnections_ConnectionConfigChanged_ReissuesCreateCommand()
|
||||
{
|
||||
var storage = NewStorage($"Data Source={_dbFile}");
|
||||
await storage.InitializeAsync();
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDeploymentManager(storage, dcl.Ref);
|
||||
await Task.Delay(500);
|
||||
|
||||
// Initial deploy with one connection.
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-c1", "ConnPump", "h1",
|
||||
MakeConfigJsonWithConnection("ConnPump", "opc.tcp://host-a:4840", 3),
|
||||
"admin", DateTimeOffset.UtcNow));
|
||||
var firstCreate = dcl.ExpectMsg<ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection.CreateConnectionCommand>(
|
||||
TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Conn1", firstCreate.ConnectionName);
|
||||
Assert.Equal(3, firstCreate.FailoverRetryCount);
|
||||
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(500);
|
||||
|
||||
// Redeploy with a CHANGED connection configuration.
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-c2", "ConnPump", "h2",
|
||||
MakeConfigJsonWithConnection("ConnPump", "opc.tcp://host-b:4840", 7),
|
||||
"admin", DateTimeOffset.UtcNow));
|
||||
|
||||
// The DCL must receive a fresh create command reflecting the new config.
|
||||
var secondCreate = dcl.ExpectMsg<ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection.CreateConnectionCommand>(
|
||||
TimeSpan.FromSeconds(10));
|
||||
Assert.Equal("Conn1", secondCreate.ConnectionName);
|
||||
Assert.Equal(7, secondCreate.FailoverRetryCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-010: an unchanged connection configuration must still be skipped —
|
||||
/// re-sending an identical create command on every deploy is wasteful.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EnsureDclConnections_UnchangedConfig_DoesNotReissueCreateCommand()
|
||||
{
|
||||
var storage = NewStorage($"Data Source={_dbFile}");
|
||||
await storage.InitializeAsync();
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDeploymentManager(storage, dcl.Ref);
|
||||
await Task.Delay(500);
|
||||
|
||||
var json = MakeConfigJsonWithConnection("StablePump", "opc.tcp://host-a:4840", 3);
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-s1", "StablePump", "h1", json, "admin", DateTimeOffset.UtcNow));
|
||||
dcl.ExpectMsg<ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection.CreateConnectionCommand>(
|
||||
TimeSpan.FromSeconds(5));
|
||||
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(500);
|
||||
|
||||
// Redeploy with the IDENTICAL connection configuration.
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-s2", "StablePump", "h2", json, "admin", DateTimeOffset.UtcNow));
|
||||
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
|
||||
|
||||
// No further create command for an unchanged connection.
|
||||
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(800));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-008: startup must not block the Deployment Manager mailbox on a
|
||||
/// synchronous shared-script load. With shared scripts present, the actor must
|
||||
/// still load deployed configs, create Instance Actors, and remain responsive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Startup_WithSharedScripts_LoadsConfigsAndStaysResponsive()
|
||||
{
|
||||
var storage = NewStorage($"Data Source={_dbFile}");
|
||||
await storage.InitializeAsync();
|
||||
|
||||
// Several shared scripts to compile during startup.
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await storage.StoreSharedScriptAsync(
|
||||
$"Shared{i}", "return 1 + 1;", null, null);
|
||||
}
|
||||
|
||||
await storage.StoreDeployedConfigAsync(
|
||||
"StartupPump", MakeConfigJson("StartupPump"), "d1", "h1", true);
|
||||
|
||||
var actor = CreateDeploymentManager(storage);
|
||||
await Task.Delay(2000);
|
||||
|
||||
// The instance loaded at startup must be operable — proves startup completed
|
||||
// and the actor processed messages after the shared-script load.
|
||||
actor.Tell(new DeploymentStateQueryRequest("corr-1", "StartupPump", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<DeploymentStateQueryResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.IsDeployed);
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for SiteRuntime-003: redeployment of an existing instance must
|
||||
/// wait for the terminating Instance Actor before recreating the child, instead of
|
||||
/// relying on a fixed 500 ms reschedule that can collide on the child actor name.
|
||||
/// </summary>
|
||||
public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly string _dbFile;
|
||||
|
||||
public DeploymentManagerRedeployTests()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"dm-redeploy-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);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
try { File.Delete(_dbFile); } catch { /* cleanup */ }
|
||||
}
|
||||
|
||||
private IActorRef CreateDeploymentManager(ISiteHealthCollector? healthCollector = null)
|
||||
{
|
||||
return ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
new SiteRuntimeOptions(),
|
||||
NullLogger<DeploymentManagerActor>.Instance,
|
||||
null,
|
||||
null,
|
||||
healthCollector,
|
||||
null)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal fake that records the most recent deployed-instance count.
|
||||
/// </summary>
|
||||
private sealed class CountCapturingHealthCollector : ISiteHealthCollector
|
||||
{
|
||||
public int LastDeployedCount { get; private set; }
|
||||
public void IncrementScriptError() { }
|
||||
public void IncrementAlarmError() { }
|
||||
public void IncrementDeadLetter() { }
|
||||
public void IncrementSiteAuditWriteFailures() { }
|
||||
public void IncrementAuditRedactionFailure() { }
|
||||
public void UpdateSiteAuditBacklog(ZB.MOM.WW.ScadaBridge.Commons.Types.SiteAuditBacklogSnapshot snapshot) { }
|
||||
public void UpdateConnectionHealth(string connectionName, ConnectionHealth health) { }
|
||||
public void RemoveConnection(string connectionName) { }
|
||||
public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved) { }
|
||||
public void UpdateConnectionEndpoint(string connectionName, string endpoint) { }
|
||||
public void UpdateTagQuality(string connectionName, int good, int bad, int uncertain) { }
|
||||
public void SetStoreAndForwardDepths(IReadOnlyDictionary<string, int> depths) { }
|
||||
public void SetInstanceCounts(int deployed, int enabled, int disabled) => LastDeployedCount = deployed;
|
||||
public void SetParkedMessageCount(int count) { }
|
||||
public void SetNodeHostname(string hostname) { }
|
||||
public void SetClusterNodes(IReadOnlyList<NodeStatus> nodes) { }
|
||||
public void SetActiveNode(bool isActive) { }
|
||||
public bool IsActiveNode => true;
|
||||
public SiteHealthReport CollectReport(string siteId) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private static string MakeConfigJson(string instanceName)
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
|
||||
]
|
||||
};
|
||||
return JsonSerializer.Serialize(config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Redeploy_ExistingInstance_SucceedsWithoutNameCollision()
|
||||
{
|
||||
var actor = CreateDeploymentManager();
|
||||
await Task.Delay(500); // empty startup
|
||||
|
||||
// Initial deploy.
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-1", "RedeployPump", "h1", MakeConfigJson("RedeployPump"), "admin", DateTimeOffset.UtcNow));
|
||||
var first = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(DeploymentStatus.Success, first.Status);
|
||||
await Task.Delay(500);
|
||||
|
||||
// Redeploy the same instance — must replace the existing actor cleanly.
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-2", "RedeployPump", "h2", MakeConfigJson("RedeployPump"), "admin", DateTimeOffset.UtcNow));
|
||||
var second = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal(DeploymentStatus.Success, second.Status);
|
||||
|
||||
// The redeployed instance must still be operable (no orphaned/broken actor).
|
||||
actor.Tell(new DisableInstanceCommand("cmd-1", "RedeployPump", DateTimeOffset.UtcNow));
|
||||
var disable = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(disable.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SR020_ThreeRapidDeploys_DoNotThrowInvalidActorNameException_LatestWins()
|
||||
{
|
||||
// Regression test for SiteRuntime-020. The previous implementation tracked
|
||||
// pending redeploys by IActorRef (_pendingRedeploys) but had no
|
||||
// name-keyed shadow, so a third DeployInstanceCommand arriving WHILE the
|
||||
// first redeploy's predecessor was still terminating saw
|
||||
// _instanceActors.TryGetValue==false and fell through to
|
||||
// ApplyDeployment → CreateInstanceActor → Context.ActorOf, which threw
|
||||
// InvalidActorNameException because the child name was still registered
|
||||
// until Terminated fires. The supervisor's Stop directive then silently
|
||||
// dropped the deploy, leaving the deployer waiting forever and the
|
||||
// persistence Task.Run dangling. After the fix, _terminatingActorsByName
|
||||
// tracks the in-flight terminator by name; the third deploy overwrites
|
||||
// the buffered pending command (last-write-wins) and tells the displaced
|
||||
// sender it was superseded.
|
||||
var actor = CreateDeploymentManager();
|
||||
await Task.Delay(500);
|
||||
|
||||
// Initial deploy — establishes the running instance.
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-1", "RapidPump", "h1", MakeConfigJson("RapidPump"), "admin", DateTimeOffset.UtcNow));
|
||||
var first = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(DeploymentStatus.Success, first.Status);
|
||||
await Task.Delay(200);
|
||||
|
||||
// Two rapid redeploys before the predecessor has time to fully terminate.
|
||||
// The second deploy stops the actor (watching it) and buffers itself.
|
||||
// The third deploy arrives almost immediately and must NOT crash — it
|
||||
// overwrites the buffered pending command and tells dep-2 it was superseded.
|
||||
var probe2 = CreateTestProbe();
|
||||
var probe3 = CreateTestProbe();
|
||||
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-2", "RapidPump", "h2", MakeConfigJson("RapidPump"), "admin", DateTimeOffset.UtcNow),
|
||||
probe2.Ref);
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-3", "RapidPump", "h3", MakeConfigJson("RapidPump"), "admin", DateTimeOffset.UtcNow),
|
||||
probe3.Ref);
|
||||
|
||||
// dep-2 must be told it was superseded; dep-3 must succeed once the
|
||||
// predecessor finishes terminating.
|
||||
var superseded = probe2.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal("dep-2", superseded.DeploymentId);
|
||||
Assert.Equal(DeploymentStatus.Failed, superseded.Status);
|
||||
Assert.NotNull(superseded.ErrorMessage);
|
||||
Assert.Contains("superseded", superseded.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var winner = probe3.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal("dep-3", winner.DeploymentId);
|
||||
Assert.Equal(DeploymentStatus.Success, winner.Status);
|
||||
|
||||
// The instance must still be operable — proves no orphaned actor / no
|
||||
// half-created child holding the name.
|
||||
actor.Tell(new DisableInstanceCommand("cmd-1", "RapidPump", DateTimeOffset.UtcNow));
|
||||
var disable = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(disable.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Redeploy_ExistingInstance_DoesNotOverCountDeployedInstances()
|
||||
{
|
||||
var health = new CountCapturingHealthCollector();
|
||||
var actor = CreateDeploymentManager(health);
|
||||
await Task.Delay(500);
|
||||
|
||||
// Deploy once.
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-1", "CountPump", "h1", MakeConfigJson("CountPump"), "admin", DateTimeOffset.UtcNow));
|
||||
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(500);
|
||||
|
||||
// Redeploy several times.
|
||||
for (var i = 2; i <= 4; i++)
|
||||
{
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
$"dep-{i}", "CountPump", $"h{i}", MakeConfigJson("CountPump"), "admin", DateTimeOffset.UtcNow));
|
||||
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
|
||||
await Task.Delay(500);
|
||||
}
|
||||
|
||||
// Storage uses UPSERT — exactly one deployed config row should exist.
|
||||
var configs = await _storage.GetAllDeployedConfigsAsync();
|
||||
Assert.Single(configs, c => c.InstanceUniqueName == "CountPump");
|
||||
|
||||
// The reported deployed count must be exactly 1 — a redeploy is an update,
|
||||
// not a new instance, so the in-memory counter must not drift upward.
|
||||
Assert.Equal(1, health.LastDeployedCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for SiteRuntime-016 — the short-lived execution actors
|
||||
/// (<see cref="ScriptExecutionActor"/>, <see cref="AlarmExecutionActor"/>) were
|
||||
/// previously untested. Covers success, exception, timeout, Ask-reply, and the
|
||||
/// PoisonPill self-stop after completion.
|
||||
/// </summary>
|
||||
public class ExecutionActorTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SharedScriptLibrary _sharedLibrary;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
|
||||
public ExecutionActorTests()
|
||||
{
|
||||
_compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
_sharedLibrary = new SharedScriptLibrary(
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
private static 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;
|
||||
}
|
||||
|
||||
private static SiteRuntimeOptions Options(int timeoutSeconds = 30)
|
||||
=> new() { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = timeoutSeconds };
|
||||
|
||||
// ── ScriptExecutionActor ──
|
||||
|
||||
[Fact]
|
||||
public void ScriptExecutionActor_Success_RepliesWithResultAndStops()
|
||||
{
|
||||
var compiled = CompileScript("return 7 * 6;");
|
||||
var replyTo = CreateTestProbe();
|
||||
var instanceActor = CreateTestProbe();
|
||||
|
||||
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||
"Answer", "Inst1", compiled, null, 0,
|
||||
instanceActor.Ref, _sharedLibrary, Options(),
|
||||
replyTo.Ref, "corr-1", NullLogger.Instance,
|
||||
ScriptScope.Root, null, null)));
|
||||
|
||||
Watch(exec);
|
||||
|
||||
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("corr-1", result.CorrelationId);
|
||||
Assert.Equal(42, result.ReturnValue);
|
||||
|
||||
// The actor must PoisonPill itself once execution completes.
|
||||
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptExecutionActor_ScriptThrows_RepliesFailureAndStops()
|
||||
{
|
||||
var compiled = CompileScript("throw new InvalidOperationException(\"boom\");");
|
||||
var replyTo = CreateTestProbe();
|
||||
var instanceActor = CreateTestProbe();
|
||||
|
||||
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||
"Bad", "Inst1", compiled, null, 0,
|
||||
instanceActor.Ref, _sharedLibrary, Options(),
|
||||
replyTo.Ref, "corr-2", NullLogger.Instance,
|
||||
ScriptScope.Root, null, null)));
|
||||
|
||||
Watch(exec);
|
||||
|
||||
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("corr-2", result.CorrelationId);
|
||||
Assert.Contains("boom", result.ErrorMessage);
|
||||
|
||||
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptExecutionActor_Timeout_RepliesFailureAndStops()
|
||||
{
|
||||
// A long busy loop that observes the cancellation token so the
|
||||
// 1-second timeout fires cooperatively.
|
||||
var compiled = CompileScript(
|
||||
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
|
||||
var replyTo = CreateTestProbe();
|
||||
var instanceActor = CreateTestProbe();
|
||||
|
||||
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||
"Slow", "Inst1", compiled, null, 0,
|
||||
instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
|
||||
replyTo.Ref, "corr-3", NullLogger.Instance,
|
||||
ScriptScope.Root, null, null)));
|
||||
|
||||
Watch(exec);
|
||||
|
||||
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("timed out", result.ErrorMessage);
|
||||
|
||||
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptExecutionActor_NoReplyTo_StillStopsAfterCompletion()
|
||||
{
|
||||
var compiled = CompileScript("return 1;");
|
||||
var instanceActor = CreateTestProbe();
|
||||
|
||||
// ActorRefs.Nobody as replyTo — fire-and-forget execution.
|
||||
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||
"FireForget", "Inst1", compiled, null, 0,
|
||||
instanceActor.Ref, _sharedLibrary, Options(),
|
||||
ActorRefs.Nobody, "corr-4", NullLogger.Instance,
|
||||
ScriptScope.Root, null, null)));
|
||||
|
||||
Watch(exec);
|
||||
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
// ── AlarmExecutionActor ──
|
||||
|
||||
[Fact]
|
||||
public void AlarmExecutionActor_Success_StopsAfterCompletion()
|
||||
{
|
||||
var compiled = CompileScript("return 0;");
|
||||
var instanceActor = CreateTestProbe();
|
||||
|
||||
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
|
||||
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
|
||||
compiled, instanceActor.Ref, _sharedLibrary, Options(),
|
||||
NullLogger.Instance)));
|
||||
|
||||
Watch(exec);
|
||||
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmExecutionActor_ScriptThrows_StillStops()
|
||||
{
|
||||
var compiled = CompileScript("throw new System.Exception(\"alarm-boom\");");
|
||||
var instanceActor = CreateTestProbe();
|
||||
|
||||
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
|
||||
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
|
||||
compiled, instanceActor.Ref, _sharedLibrary, Options(),
|
||||
NullLogger.Instance)));
|
||||
|
||||
Watch(exec);
|
||||
// Even on a throwing on-trigger body, the actor must self-stop.
|
||||
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for SiteRuntime-017 — the Instance Actor must not hand its
|
||||
/// own live mutable <c>_attributes</c> dictionary by reference into the
|
||||
/// <see cref="ScriptActor"/> / <see cref="AlarmActor"/> constructors.
|
||||
///
|
||||
/// Each child constructor runs on the child's own mailbox thread and seeds itself
|
||||
/// by enumerating the dictionary it was given. The Instance Actor concurrently
|
||||
/// mutates <c>_attributes</c> in <c>HandleAttributeValueChanged</c> /
|
||||
/// <c>HandleTagValueUpdate</c>. <see cref="Dictionary{TKey,TValue}"/> is not safe
|
||||
/// for concurrent read/write: if a child enumerates the shared live dictionary
|
||||
/// while the Instance Actor inserts into it, the child constructor throws
|
||||
/// <see cref="InvalidOperationException"/> ("collection was modified") — surfacing
|
||||
/// as <c>ActorInitializationException</c> and stopping the child.
|
||||
///
|
||||
/// The fix: <c>CreateChildActors</c> snapshots <c>_attributes</c> once on the
|
||||
/// Instance Actor thread (<c>new Dictionary<,>(_attributes)</c>) and hands
|
||||
/// each child that private copy. This test asserts the isolation contract
|
||||
/// directly and deterministically: every child's seed dictionary must be a
|
||||
/// distinct object from the Instance Actor's live <c>_attributes</c>, while still
|
||||
/// carrying the same point-in-time contents.
|
||||
/// </summary>
|
||||
public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly string _dbFile;
|
||||
|
||||
public InstanceActorChildAttributeRaceTests()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"instance-race-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 static FlattenedConfiguration BuildConfig(string instanceName)
|
||||
=> new()
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" },
|
||||
new ResolvedAttribute { CanonicalName = "Pressure", Value = "12", DataType = "Int32" },
|
||||
new ResolvedAttribute { CanonicalName = "Label", Value = "Main Pump", DataType = "String" }
|
||||
],
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "WorkerA", Code = "return 1;",
|
||||
TriggerType = "ValueChange",
|
||||
TriggerConfiguration = "{\"AttributeName\":\"Temperature\"}"
|
||||
},
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "WorkerB", Code = "return 2;",
|
||||
TriggerType = "ValueChange",
|
||||
TriggerConfiguration = "{\"AttributeName\":\"Pressure\"}"
|
||||
}
|
||||
],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{}",
|
||||
PriorityLevel = 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/// <summary>Resolves the live actor instance behind a local <see cref="IActorRef"/>.</summary>
|
||||
private static object GetActorInstance(IActorRef actorRef)
|
||||
{
|
||||
var cell = ((ActorRefWithCell)actorRef).Underlying;
|
||||
// ActorCell exposes the actor instance via its internal Actor property.
|
||||
var actorProp = cell.GetType().GetProperty(
|
||||
"Actor", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
|
||||
var instance = actorProp!.GetValue(cell);
|
||||
Assert.NotNull(instance);
|
||||
return instance!;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> GetPrivateAttributes(InstanceActor instance)
|
||||
{
|
||||
var field = typeof(InstanceActor).GetField(
|
||||
"_attributes", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
return (Dictionary<string, object?>)field!.GetValue(instance)!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChildActors_AreSeededFromAnIsolatedCopy_NotTheLiveAttributesDictionary()
|
||||
{
|
||||
const string instanceName = "RacePump";
|
||||
var config = BuildConfig(instanceName);
|
||||
|
||||
var testRef = ActorOfAsTestActorRef<InstanceActor>(
|
||||
Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance)),
|
||||
"instance");
|
||||
|
||||
var instanceActor = testRef.UnderlyingActor;
|
||||
var liveAttributes = GetPrivateAttributes(instanceActor);
|
||||
|
||||
// Sanity: the children were created.
|
||||
Assert.Equal(2, instanceActor.ScriptActorCount);
|
||||
Assert.Equal(1, instanceActor.AlarmActorCount);
|
||||
|
||||
// Every child Script Actor must have been seeded from a dictionary that
|
||||
// is NOT the Instance Actor's live _attributes field — otherwise the
|
||||
// child constructor would enumerate a dictionary the Instance Actor
|
||||
// mutates on another thread (SiteRuntime-017).
|
||||
foreach (var name in new[] { "WorkerA", "WorkerB" })
|
||||
{
|
||||
var child = await Sys.ActorSelection(testRef.Path / $"script-{name}")
|
||||
.ResolveOne(TimeSpan.FromSeconds(5));
|
||||
var scriptActor = (ScriptActor)GetActorInstance(child);
|
||||
|
||||
Assert.NotNull(scriptActor.SeedAttributesReference);
|
||||
Assert.False(
|
||||
ReferenceEquals(scriptActor.SeedAttributesReference, liveAttributes),
|
||||
$"Script Actor '{name}' was seeded from the Instance Actor's live " +
|
||||
"_attributes dictionary by reference (SiteRuntime-017). It must be " +
|
||||
"given a private snapshot copy.");
|
||||
|
||||
// The snapshot must still carry the same point-in-time contents.
|
||||
Assert.Equal(liveAttributes.Count, scriptActor.SeedAttributesReference!.Count);
|
||||
foreach (var kvp in liveAttributes)
|
||||
{
|
||||
Assert.True(scriptActor.SeedAttributesReference.ContainsKey(kvp.Key));
|
||||
}
|
||||
}
|
||||
|
||||
// The Alarm Actor must likewise be seeded from an isolated copy.
|
||||
var alarmChild = await Sys.ActorSelection(testRef.Path / "alarm-HighTemp")
|
||||
.ResolveOne(TimeSpan.FromSeconds(5));
|
||||
var alarmActor = (AlarmActor)GetActorInstance(alarmChild);
|
||||
|
||||
Assert.NotNull(alarmActor.SeedAttributesReference);
|
||||
Assert.False(
|
||||
ReferenceEquals(alarmActor.SeedAttributesReference, liveAttributes),
|
||||
"Alarm Actor 'HighTemp' was seeded from the Instance Actor's live " +
|
||||
"_attributes dictionary by reference (SiteRuntime-017). It must be " +
|
||||
"given a private snapshot copy.");
|
||||
Assert.Equal(liveAttributes.Count, alarmActor.SeedAttributesReference!.Count);
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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));
|
||||
}
|
||||
|
||||
// Each static write replies with a SetStaticAttributeResponse; drain all
|
||||
// 50 — the FIFO mailbox guarantees they are processed in order.
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
// 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_DebugViewSubscribe_NoLongerForwardsEventsViaClusterClient()
|
||||
{
|
||||
// Events now flow via gRPC (SiteStreamManager → StreamRelayActor → gRPC),
|
||||
// not via ClusterClient. Subscribing returns a snapshot but ongoing events
|
||||
// are NOT forwarded to the subscriber actor.
|
||||
var actor = CreateInstanceWithScripts("Pump1");
|
||||
|
||||
// Subscribe to debug view — should still get snapshot
|
||||
actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-2"));
|
||||
ExpectMsg<DebugViewSnapshot>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Change an attribute
|
||||
actor.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "200", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
// Should NOT receive change notification (old ClusterClient path removed)
|
||||
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
// Wait for initialization
|
||||
Thread.Sleep(500);
|
||||
|
||||
// Verify alarm actor was created by checking the debug snapshot includes the alarm
|
||||
actor.Tell(new DebugSnapshotRequest("Pump1", "snap-alarm"));
|
||||
var snapshot = ExpectMsg<DebugViewSnapshot>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Single(snapshot.AlarmStates);
|
||||
Assert.Equal("HighTemp", snapshot.AlarmStates[0].AlarmName);
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Akka.TestKit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for SiteRuntime-001: Instance.SetAttribute must route writes
|
||||
/// to the Data Connection Layer for data-sourced attributes instead of persisting
|
||||
/// a local static override.
|
||||
/// </summary>
|
||||
public class InstanceActorSetAttributeTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly string _dbFile;
|
||||
|
||||
public InstanceActorSetAttributeTests()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"instance-setattr-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();
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
try { File.Delete(_dbFile); } catch { /* cleanup */ }
|
||||
}
|
||||
|
||||
private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config, IActorRef? dclManager)
|
||||
{
|
||||
return ActorOf(Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dclManager)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains the startup <see cref="SubscribeTagsRequest"/> the Instance Actor emits
|
||||
/// to the DCL in PreStart, then returns the next <see cref="WriteTagRequest"/>.
|
||||
/// </summary>
|
||||
private static WriteTagRequest ExpectWriteTag(TestProbe dclProbe)
|
||||
=> dclProbe.FishForMessage<WriteTagRequest>(_ => true, TimeSpan.FromSeconds(5));
|
||||
|
||||
private static FlattenedConfiguration DataSourcedConfig(string instanceName) => new()
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Setpoint",
|
||||
Value = "10",
|
||||
DataType = "Double",
|
||||
DataSourceReference = "/Motor/Setpoint",
|
||||
BoundDataConnectionName = "OpcServer1"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttribute_DataSourcedAttribute_IssuesDclWriteAndDoesNotPersistOverride()
|
||||
{
|
||||
var config = DataSourcedConfig("PumpDcl1");
|
||||
var dclProbe = CreateTestProbe();
|
||||
var actor = CreateInstanceActor("PumpDcl1", config, dclProbe.Ref);
|
||||
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-dcl", "PumpDcl1", "Setpoint", "55", DateTimeOffset.UtcNow));
|
||||
|
||||
// The Instance Actor must forward a WriteTagRequest to the DCL manager.
|
||||
var write = ExpectWriteTag(dclProbe);
|
||||
Assert.Equal("OpcServer1", write.ConnectionName);
|
||||
Assert.Equal("/Motor/Setpoint", write.TagPath);
|
||||
Assert.Equal("55", write.Value);
|
||||
|
||||
// DCL confirms the write.
|
||||
dclProbe.Reply(new WriteTagResponse(write.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Success);
|
||||
|
||||
// No static override should be persisted for a data-sourced attribute.
|
||||
await Task.Delay(300);
|
||||
var overrides = await _storage.GetStaticOverridesAsync("PumpDcl1");
|
||||
Assert.Empty(overrides);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetAttribute_DataSourcedAttribute_DoesNotOptimisticallyUpdateMemory()
|
||||
{
|
||||
var config = DataSourcedConfig("PumpDcl2");
|
||||
var dclProbe = CreateTestProbe();
|
||||
var actor = CreateInstanceActor("PumpDcl2", config, dclProbe.Ref);
|
||||
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-dcl2", "PumpDcl2", "Setpoint", "999", DateTimeOffset.UtcNow));
|
||||
|
||||
var write = ExpectWriteTag(dclProbe);
|
||||
dclProbe.Reply(new WriteTagResponse(write.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// In-memory value must still be the original config value — it is only
|
||||
// updated when the subscription delivers the confirmed device value.
|
||||
actor.Tell(new GetAttributeRequest("corr-get", "PumpDcl2", "Setpoint", DateTimeOffset.UtcNow));
|
||||
var get = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("10", get.Value?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetAttribute_DataSourcedAttribute_DclWriteFailure_ReturnedToCaller()
|
||||
{
|
||||
var config = DataSourcedConfig("PumpDcl3");
|
||||
var dclProbe = CreateTestProbe();
|
||||
var actor = CreateInstanceActor("PumpDcl3", config, dclProbe.Ref);
|
||||
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-dcl3", "PumpDcl3", "Setpoint", "42", DateTimeOffset.UtcNow));
|
||||
|
||||
var write = ExpectWriteTag(dclProbe);
|
||||
dclProbe.Reply(new WriteTagResponse(write.CorrelationId, false, "device rejected write", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.False(response.Success);
|
||||
Assert.Contains("device rejected write", response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttribute_StaticAttribute_StillPersistsOverrideAndDoesNotCallDcl()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "PumpStatic1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Label", Value = "Main", DataType = "String" }
|
||||
]
|
||||
};
|
||||
var dclProbe = CreateTestProbe();
|
||||
var actor = CreateInstanceActor("PumpStatic1", config, dclProbe.Ref);
|
||||
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-static", "PumpStatic1", "Label", "Backup", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Success);
|
||||
|
||||
// DCL must NOT receive a write for a static attribute.
|
||||
dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
await Task.Delay(300);
|
||||
var overrides = await _storage.GetStaticOverridesAsync("PumpStatic1");
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("Backup", overrides["Label"]);
|
||||
}
|
||||
|
||||
// SiteRuntime-025: SetAttribute on an unknown attribute name must NOT
|
||||
// pollute the in-memory dictionary, NOT publish a synthetic
|
||||
// AttributeValueChanged, and NOT persist a durable override row.
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttribute_UnknownAttribute_ReturnsFailureAndDoesNotPersistOverride()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "PumpUnknown",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Label", Value = "Main", DataType = "String" }
|
||||
]
|
||||
};
|
||||
var dclProbe = CreateTestProbe();
|
||||
var actor = CreateInstanceActor("PumpUnknown", config, dclProbe.Ref);
|
||||
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-unknown", "PumpUnknown", "notARealAttr", "x", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.False(response.Success);
|
||||
Assert.Contains("Unknown attribute", response.ErrorMessage);
|
||||
Assert.Contains("notARealAttr", response.ErrorMessage);
|
||||
|
||||
// The DCL must NOT receive any write — the attribute does not exist.
|
||||
dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// No durable override row should be persisted for an unknown attribute —
|
||||
// otherwise the polluting key resurrects on every restart via
|
||||
// HandleOverridesLoaded.
|
||||
await Task.Delay(300);
|
||||
var overrides = await _storage.GetStaticOverridesAsync("PumpUnknown");
|
||||
Assert.DoesNotContain("notARealAttr", overrides.Keys);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for InstanceActor: attribute loading, static overrides, and persistence.
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"instance-actor-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();
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
Shutdown();
|
||||
try { File.Delete(_dbFile); } catch { /* cleanup */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceActor_LoadsAttributesFromConfig()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" },
|
||||
new ResolvedAttribute { CanonicalName = "Status", Value = "Running", DataType = "String" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
// Query for an attribute that exists
|
||||
actor.Tell(new GetAttributeRequest(
|
||||
"corr-1", "Pump1", "Temperature", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<GetAttributeResponse>();
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("98.6", response.Value?.ToString());
|
||||
Assert.Equal("corr-1", response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceActor_GetAttribute_NotFound_ReturnsFalse()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes = []
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
actor.Tell(new GetAttributeRequest(
|
||||
"corr-2", "Pump1", "NonExistent", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<GetAttributeResponse>();
|
||||
Assert.False(response.Found);
|
||||
Assert.Null(response.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceActor_SetStaticAttribute_UpdatesInMemory()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
// A static attribute write replies with SetStaticAttributeResponse.
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-3", "Pump1", "Temperature", "100.0", DateTimeOffset.UtcNow));
|
||||
var setResponse = ExpectMsg<SetStaticAttributeResponse>();
|
||||
Assert.True(setResponse.Success);
|
||||
|
||||
// Verify the value changed in memory
|
||||
actor.Tell(new GetAttributeRequest(
|
||||
"corr-4", "Pump1", "Temperature", DateTimeOffset.UtcNow));
|
||||
|
||||
var getResponse = ExpectMsg<GetAttributeResponse>();
|
||||
Assert.True(getResponse.Found);
|
||||
Assert.Equal("100.0", getResponse.Value?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstanceActor_SetStaticAttribute_PersistsToSQLite()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "PumpPersist1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("PumpPersist1", config);
|
||||
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-persist", "PumpPersist1", "Temperature", "100.0", DateTimeOffset.UtcNow));
|
||||
|
||||
// A static attribute write replies with SetStaticAttributeResponse once the
|
||||
// in-memory state is updated; then wait for the async SQLite persist.
|
||||
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(500);
|
||||
|
||||
// Verify it persisted to SQLite
|
||||
var overrides = await _storage.GetStaticOverridesAsync("PumpPersist1");
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("100.0", overrides["Temperature"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstanceActor_LoadsStaticOverridesFromSQLite()
|
||||
{
|
||||
// Pre-populate overrides in SQLite
|
||||
await _storage.SetStaticOverrideAsync("PumpOverride1", "Temperature", "200.0");
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "PumpOverride1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("PumpOverride1", config);
|
||||
|
||||
// Wait for the async override loading to complete (PipeTo)
|
||||
await Task.Delay(1000);
|
||||
|
||||
actor.Tell(new GetAttributeRequest(
|
||||
"corr-5", "PumpOverride1", "Temperature", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<GetAttributeResponse>();
|
||||
Assert.True(response.Found);
|
||||
// The override value should take precedence over the config default
|
||||
Assert.Equal("200.0", response.Value?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticOverride_ResetOnRedeployment()
|
||||
{
|
||||
// Set up an override
|
||||
await _storage.SetStaticOverrideAsync("PumpRedeploy", "Temperature", "200.0");
|
||||
|
||||
// Verify override exists
|
||||
var overrides = await _storage.GetStaticOverridesAsync("PumpRedeploy");
|
||||
Assert.Single(overrides);
|
||||
|
||||
// Clear overrides (simulates what DeploymentManager does on redeployment)
|
||||
await _storage.ClearStaticOverridesAsync("PumpRedeploy");
|
||||
|
||||
overrides = await _storage.GetStaticOverridesAsync("PumpRedeploy");
|
||||
Assert.Empty(overrides);
|
||||
|
||||
// Create actor with fresh config -- should NOT have the override
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "PumpRedeploy",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("PumpRedeploy", config);
|
||||
|
||||
await Task.Delay(1000);
|
||||
|
||||
actor.Tell(new GetAttributeRequest(
|
||||
"corr-6", "PumpRedeploy", "Temperature", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<GetAttributeResponse>();
|
||||
Assert.Equal("98.6", response.Value?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceActor_DataSourcedAttribute_StartsWithUncertainQuality()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Temperature",
|
||||
Value = "0",
|
||||
DataType = "Double",
|
||||
DataSourceReference = "/Motor/Temperature",
|
||||
BoundDataConnectionName = "OpcServer1"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
actor.Tell(new GetAttributeRequest(
|
||||
"corr-quality-1", "Pump1", "Temperature", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<GetAttributeResponse>();
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Uncertain", response.Quality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-019: the disable/enable lifecycle is owned entirely by the
|
||||
/// Deployment Manager (it stops / re-creates the Instance Actor itself and
|
||||
/// replies to the caller). The Instance Actor must NOT handle
|
||||
/// <see cref="DisableInstanceCommand"/> / <see cref="EnableInstanceCommand"/>
|
||||
/// — the dead handlers that replied with a misleading "success"
|
||||
/// acknowledgement were removed. Sending one to the Instance Actor now goes
|
||||
/// unhandled and produces no <see cref="InstanceLifecycleResponse"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_DoesNotHandleDisableOrEnableCommands()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes = []
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
actor.Tell(new DisableInstanceCommand("cmd-disable", "Pump1", DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
actor.Tell(new EnableInstanceCommand("cmd-enable", "Pump1", DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceActor_StaticAttribute_StartsWithGoodQuality()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Label",
|
||||
Value = "Main Pump",
|
||||
DataType = "String"
|
||||
// No DataSourceReference — static attribute
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
actor.Tell(new GetAttributeRequest(
|
||||
"corr-quality-2", "Pump1", "Label", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<GetAttributeResponse>();
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single PLC tag can back more than one attribute — e.g. two composed
|
||||
/// cooling-tank modules whose members both reference the one simulated
|
||||
/// <c>ns=3;s=Tank.Level</c> node. A <see cref="TagValueUpdate"/> must fan out
|
||||
/// to every attribute that references that tag path, not just the last one
|
||||
/// registered: the tag-path → attribute map previously overwrote on a shared
|
||||
/// tag, leaving all but one attribute permanently Uncertain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_TagUpdate_FansOutToEveryAttributeSharingTheTagPath()
|
||||
{
|
||||
const string sharedTag = "ns=3;s=Tank.Level";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Motor-1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "CoolingTank.Level", Value = "0", DataType = "Int",
|
||||
DataSourceReference = sharedTag, BoundDataConnectionName = "PLC"
|
||||
},
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "CoolingTank2.Level", Value = "0", DataType = "Int",
|
||||
DataSourceReference = sharedTag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"Motor-1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
// On startup the actor subscribes its data-sourced tags through the DCL.
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// One value arrives for the tag that both attributes reference.
|
||||
actor.Tell(new TagValueUpdate("PLC", sharedTag, 47, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
// BOTH attributes must reflect it — not just the last-registered one.
|
||||
foreach (var attrName in new[] { "CoolingTank.Level", "CoolingTank2.Level" })
|
||||
{
|
||||
actor.Tell(new GetAttributeRequest("corr-fanout", "Motor-1", attrName, DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("47", response.Value?.ToString());
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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
|
||||
}
|
||||
|
||||
// ── WhileTrue trigger mode (Conditional + Expression) ──────────────────
|
||||
//
|
||||
// A fired script runs `Instance.SetAttribute("Fired", "1")`, which the
|
||||
// Instance Actor receives as a SetStaticAttributeCommand. The probe stands
|
||||
// in for the Instance Actor: an auto-pilot replies so each execution
|
||||
// completes promptly (freeing the script-execution scheduler), while every
|
||||
// command remains observable via ExpectMsg — one command per script firing.
|
||||
|
||||
private const string FiringScriptCode = "await Instance.SetAttribute(\"Fired\", \"1\")";
|
||||
|
||||
/// <summary>Builds a ScriptActor whose script fires one observable command per run.</summary>
|
||||
private (IActorRef Actor, TestProbe Instance) CreateTriggeredActor(
|
||||
string name,
|
||||
string triggerType,
|
||||
string triggerConfig,
|
||||
TimeSpan? minTimeBetweenRuns,
|
||||
Script<object?>? triggerExpression = null)
|
||||
{
|
||||
var compiled = CompileScript(FiringScriptCode);
|
||||
var scriptConfig = new ResolvedScript
|
||||
{
|
||||
CanonicalName = name,
|
||||
Code = FiringScriptCode,
|
||||
TriggerType = triggerType,
|
||||
TriggerConfiguration = triggerConfig,
|
||||
MinTimeBetweenRuns = minTimeBetweenRuns
|
||||
};
|
||||
|
||||
var instance = CreateTestProbe();
|
||||
instance.SetAutoPilot(new DelegateAutoPilot((sender, message) =>
|
||||
{
|
||||
if (message is SetStaticAttributeCommand cmd)
|
||||
{
|
||||
sender.Tell(new SetStaticAttributeResponse(
|
||||
cmd.CorrelationId, cmd.InstanceUniqueName, cmd.AttributeName,
|
||||
true, null, DateTimeOffset.UtcNow));
|
||||
}
|
||||
return AutoPilot.KeepRunning;
|
||||
}));
|
||||
|
||||
var actor = ActorOf(Props.Create(() => new ScriptActor(
|
||||
name,
|
||||
"TestInstance",
|
||||
instance.Ref,
|
||||
compiled,
|
||||
scriptConfig,
|
||||
_sharedLibrary,
|
||||
_options,
|
||||
NullLogger<ScriptActor>.Instance,
|
||||
triggerExpression,
|
||||
null,
|
||||
null,
|
||||
null)));
|
||||
|
||||
return (actor, instance);
|
||||
}
|
||||
|
||||
private AttributeValueChanged Change(string attribute, object? value) =>
|
||||
new("TestInstance", attribute, attribute, value, "Good", DateTimeOffset.UtcNow);
|
||||
|
||||
private Script<object?> CompileTriggerExpression(string expression) =>
|
||||
_compilationService.CompileTriggerExpression("trigger-expr", expression).CompiledScript!;
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds()
|
||||
{
|
||||
// WhileTrue re-fire cadence is the script's MinTimeBetweenRuns.
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"CondWhile",
|
||||
"Conditional",
|
||||
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
|
||||
TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Temp 100 > 50 -> false->true edge: fire immediately.
|
||||
actor.Tell(Change("Temp", "100"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
|
||||
|
||||
// Then the timer re-fires while the condition still holds.
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 1
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalWhileTrue_StopsReFiringWhenConditionGoesFalse()
|
||||
{
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"CondStop",
|
||||
"Conditional",
|
||||
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
|
||||
TimeSpan.FromMilliseconds(300));
|
||||
|
||||
actor.Tell(Change("Temp", "100"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // at least one tick
|
||||
|
||||
// Temp 10 -> condition false: the re-fire timer stops.
|
||||
actor.Tell(Change("Temp", "10"));
|
||||
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(700)); // re-firing has stopped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalWhileTrue_ReArmsAfterConditionFalseThenTrueAgain()
|
||||
{
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"CondReArm",
|
||||
"Conditional",
|
||||
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
|
||||
TimeSpan.FromMilliseconds(300));
|
||||
|
||||
actor.Tell(Change("Temp", "100")); // true edge -> fire
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
|
||||
actor.Tell(Change("Temp", "10")); // false -> stop
|
||||
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
actor.Tell(Change("Temp", "100")); // false->true again: re-arm + fire
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalWhileTrue_WithoutMinTimeBetweenRuns_FiresOnceOnly()
|
||||
{
|
||||
// No MinTimeBetweenRuns -> no re-fire interval: degrades to a single edge fire.
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"CondNoInterval",
|
||||
"Conditional",
|
||||
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
|
||||
minTimeBetweenRuns: null);
|
||||
|
||||
actor.Tell(Change("Temp", "100"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(900)); // no repeats
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalOnTrue_FiresOnEachChangeWhileTrue_NoTimer()
|
||||
{
|
||||
// Regression: OnTrue (the existing behavior) fires per matching change
|
||||
// and never re-fires on a timer of its own.
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"CondOnTrue",
|
||||
"Conditional",
|
||||
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"OnTrue\"}",
|
||||
minTimeBetweenRuns: null);
|
||||
|
||||
actor.Tell(Change("Temp", "100"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
|
||||
actor.Tell(Change("Temp", "101"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
|
||||
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(600)); // no self-driven re-fire
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ExpressionWhileTrue_ReFiresWhileExpressionHolds()
|
||||
{
|
||||
var triggerExpr = CompileTriggerExpression("Attributes[\"Active\"]?.ToString() == \"yes\"");
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"ExprWhile",
|
||||
"Expression",
|
||||
"{\"expression\":\"Attributes[\\\"Active\\\"]?.ToString() == \\\"yes\\\"\",\"mode\":\"WhileTrue\"}",
|
||||
TimeSpan.FromMilliseconds(300),
|
||||
triggerExpr);
|
||||
|
||||
actor.Tell(Change("Active", "yes"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 1
|
||||
|
||||
actor.Tell(Change("Active", "no"));
|
||||
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ExpressionOnTrue_FiresOncePerFalseToTrueEdge()
|
||||
{
|
||||
// Regression: OnTrue expression triggers stay edge-triggered.
|
||||
var triggerExpr = CompileTriggerExpression("Attributes[\"Active\"]?.ToString() == \"yes\"");
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"ExprOnTrue",
|
||||
"Expression",
|
||||
"{\"expression\":\"Attributes[\\\"Active\\\"]?.ToString() == \\\"yes\\\"\",\"mode\":\"OnTrue\"}",
|
||||
minTimeBetweenRuns: null,
|
||||
triggerExpr);
|
||||
|
||||
actor.Tell(Change("Active", "yes"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
|
||||
actor.Tell(Change("Active", "yes")); // still true, no edge
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
actor.Tell(Change("Active", "no")); // -> false
|
||||
actor.Tell(Change("Active", "yes")); // false->true edge again
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user