refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,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}");
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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&lt;,&gt;(_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);
}
}
@@ -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);
}
}
@@ -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));
}
}