320 lines
13 KiB
C#
320 lines
13 KiB
C#
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.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 ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
|
|
using ZB.MOM.WW.ScadaBridge.TestSupport;
|
|
using System.Text.Json;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
|
|
|
/// <summary>
|
|
/// Audit Log #23 (M5.4 T4 — <c>ParentExecutionId</c> tag-cascade, alarm leg):
|
|
/// an alarm on-trigger script run must chain under the execution whose attribute
|
|
/// write fired the alarm, and must stay a tree ROOT when the firing value had no
|
|
/// audited origin.
|
|
///
|
|
/// <para>The cascade is three hops, each pinned here:</para>
|
|
/// <list type="number">
|
|
/// <item><description>
|
|
/// <c>ScriptRuntimeContext.SetAttribute</c> stamps the running script's
|
|
/// <c>ExecutionId</c> onto <see cref="SetStaticAttributeCommand.SourceExecutionId"/>.
|
|
/// </description></item>
|
|
/// <item><description>
|
|
/// The Instance Actor carries that id onto the published
|
|
/// <see cref="AttributeValueChanged.SourceExecutionId"/>.
|
|
/// </description></item>
|
|
/// <item><description>
|
|
/// The Alarm Actor hands it to the spawned <c>AlarmExecutionActor</c> as the
|
|
/// on-trigger run's <c>ParentExecutionId</c> — observable via
|
|
/// <c>AlarmActor.LastOnTriggerParentExecutionId</c>.
|
|
/// </description></item>
|
|
/// </list>
|
|
///
|
|
/// <para>
|
|
/// A value arriving from the Data Connection Layer (external device data) carries
|
|
/// no <c>SourceExecutionId</c>, so the alarm it fires is correctly parentless.
|
|
/// </para>
|
|
/// </summary>
|
|
public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
|
|
{
|
|
private readonly ScriptCompilationService _compilationService;
|
|
private readonly SharedScriptLibrary _sharedLibrary;
|
|
private readonly SiteRuntimeOptions _options;
|
|
private readonly TestLocalDb _localDb;
|
|
private readonly SiteStorageService _storage;
|
|
|
|
public AlarmCascadeParentExecutionTests()
|
|
{
|
|
_compilationService = new ScriptCompilationService(
|
|
NullLogger<ScriptCompilationService>.Instance);
|
|
_sharedLibrary = new SharedScriptLibrary(
|
|
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
|
_options = new SiteRuntimeOptions();
|
|
_localDb = TestLocalDb.CreateTemp("alarm-cascade-test");
|
|
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
|
|
_storage.InitializeAsync().GetAwaiter().GetResult();
|
|
}
|
|
|
|
void IDisposable.Dispose()
|
|
{
|
|
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
|
// then dispose the database before deleting — the master connection anchors the WAL.
|
|
Shutdown();
|
|
var path = _localDb.Path;
|
|
_localDb.Dispose();
|
|
TestLocalDb.DeleteFiles(path);
|
|
}
|
|
|
|
/// <summary>A trivially-succeeding on-trigger script, so the alarm actually spawns an execution.</summary>
|
|
private Script<object?> OnTriggerScript()
|
|
{
|
|
var compiled = _compilationService.Compile("OnTrigger", "return null;");
|
|
Assert.NotNull(compiled.CompiledScript);
|
|
return compiled.CompiledScript!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compiles a trigger expression outside the trust validator (mirrors
|
|
/// <c>AlarmActorTests.CompileRawTriggerExpression</c>).
|
|
/// </summary>
|
|
private static Script<object?> CompileTriggerExpression(string expression)
|
|
{
|
|
var opts = ScriptOptions.Default
|
|
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
|
|
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
|
|
var script = CSharpScript.Create<object?>(expression, opts, typeof(TriggerExpressionGlobals));
|
|
script.Compile();
|
|
return script;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds an Alarm Actor as a <c>TestActorRef</c> so the spawn-time
|
|
/// <c>ParentExecutionId</c> can be read off the underlying actor (the spawned
|
|
/// child builds its <c>ScriptRuntimeContext</c> internally and exposes nothing).
|
|
/// </summary>
|
|
private TestActorRef<AlarmActor> BuildAlarm(
|
|
ResolvedAlarm config, out Akka.TestKit.TestProbe instanceProbe,
|
|
Script<object?>? triggerExpression = null)
|
|
{
|
|
var probe = CreateTestProbe();
|
|
instanceProbe = probe;
|
|
return ActorOfAsTestActorRef<AlarmActor>(
|
|
Props.Create(() => new AlarmActor(
|
|
config.CanonicalName, "Pump1", probe.Ref, config,
|
|
OnTriggerScript(), _sharedLibrary, _options,
|
|
NullLogger<AlarmActor>.Instance, triggerExpression)),
|
|
"alarm-" + Guid.NewGuid().ToString("N"));
|
|
}
|
|
|
|
private static ResolvedAlarm ValueMatchAlarm() => new()
|
|
{
|
|
CanonicalName = "HighTemp",
|
|
TriggerType = "ValueMatch",
|
|
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
|
PriorityLevel = 1
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Hop 3 — Alarm Actor → AlarmExecutionActor
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void ScriptWrittenValue_FiringAlarm_ParentsOnTriggerRunToTheWritingExecution()
|
|
{
|
|
var writerExecutionId = Guid.NewGuid();
|
|
var alarm = BuildAlarm(ValueMatchAlarm(), out var instanceProbe);
|
|
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow,
|
|
SourceExecutionId: writerExecutionId));
|
|
|
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
|
|
}
|
|
|
|
[Fact]
|
|
public void DclOriginatedValue_FiringAlarm_LeavesOnTriggerRunAsATreeRoot()
|
|
{
|
|
// External device data has no spawning execution: null is the CORRECT
|
|
// answer here, not a gap. The on-trigger run is a tree root.
|
|
var alarm = BuildAlarm(ValueMatchAlarm(), out var instanceProbe);
|
|
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
|
|
|
// The alarm really did raise (and therefore really did spawn the
|
|
// on-trigger run) — so the null below is a recorded root, not a no-op.
|
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Null(alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
|
|
}
|
|
|
|
[Fact]
|
|
public void HiLoAlarm_EnteringABand_ParentsOnTriggerRunToTheWritingExecution()
|
|
{
|
|
var writerExecutionId = Guid.NewGuid();
|
|
var alarm = BuildAlarm(
|
|
new ResolvedAlarm
|
|
{
|
|
CanonicalName = "TempBand",
|
|
TriggerType = "HiLo",
|
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":95}",
|
|
PriorityLevel = 1
|
|
},
|
|
out var instanceProbe);
|
|
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "Temp", "Temp", 90.0, "Good", DateTimeOffset.UtcNow,
|
|
SourceExecutionId: writerExecutionId));
|
|
|
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExpressionAlarm_CarriesTheWriterCapturedWithTheEvaluatedSnapshot()
|
|
{
|
|
// Expression triggers evaluate a whole snapshot OFF the dispatcher, so the
|
|
// firing change is no longer in scope when the boolean comes back. The
|
|
// writer is captured alongside the snapshot and echoed home on the result.
|
|
var writerExecutionId = Guid.NewGuid();
|
|
var alarm = BuildAlarm(
|
|
new ResolvedAlarm
|
|
{
|
|
CanonicalName = "ExprAlarm",
|
|
TriggerType = "Expression",
|
|
TriggerConfiguration = "{\"expression\":\"true\"}",
|
|
PriorityLevel = 1
|
|
},
|
|
out _,
|
|
CompileTriggerExpression("true"));
|
|
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "A", "A", 1, "Good", DateTimeOffset.UtcNow,
|
|
SourceExecutionId: writerExecutionId));
|
|
|
|
AwaitAssert(
|
|
() => Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId),
|
|
TimeSpan.FromSeconds(10));
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Hop 1 — ScriptRuntimeContext.SetAttribute stamps the running execution
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task SetAttribute_StampsTheRunningExecutionIdOnTheWriteCommand()
|
|
{
|
|
var probe = CreateTestProbe();
|
|
var executionId = Guid.NewGuid();
|
|
var context = new ScriptRuntimeContext(
|
|
probe.Ref,
|
|
_sharedLibrary,
|
|
currentCallDepth: 0,
|
|
maxCallDepth: 10,
|
|
askTimeout: TimeSpan.FromSeconds(5),
|
|
instanceName: "Pump1",
|
|
logger: NullLogger.Instance,
|
|
executionId: executionId);
|
|
|
|
var write = context.SetAttribute("Status", "Critical");
|
|
|
|
var command = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(executionId, command.SourceExecutionId);
|
|
|
|
probe.Reply(new SetStaticAttributeResponse(
|
|
command.CorrelationId, "Pump1", "Status", true, null, DateTimeOffset.UtcNow));
|
|
await write;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Hop 2 — Instance Actor carries the writer onto the published change
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void InstanceActor_StaticWrite_PublishesTheWritingExecutionOnTheChange()
|
|
{
|
|
var streamManager = new SiteStreamManager(
|
|
new SiteRuntimeOptions { StreamBufferSize = 100 },
|
|
NullLogger<SiteStreamManager>.Instance);
|
|
streamManager.Initialize(Sys);
|
|
|
|
var config = new FlattenedConfiguration
|
|
{
|
|
InstanceUniqueName = "Pump1",
|
|
Attributes =
|
|
[
|
|
new ResolvedAttribute { CanonicalName = "Status", Value = "Normal", DataType = "String" }
|
|
]
|
|
};
|
|
|
|
var instance = ActorOf(Props.Create(() => new InstanceActor(
|
|
"Pump1",
|
|
JsonSerializer.Serialize(config),
|
|
_storage,
|
|
_compilationService,
|
|
_sharedLibrary,
|
|
streamManager,
|
|
_options,
|
|
NullLogger<InstanceActor>.Instance)));
|
|
|
|
var subscriber = CreateTestProbe();
|
|
streamManager.Subscribe("Pump1", subscriber.Ref);
|
|
|
|
var writerExecutionId = Guid.NewGuid();
|
|
instance.Tell(new SetStaticAttributeCommand(
|
|
"corr-1", "Pump1", "Status", "Critical", DateTimeOffset.UtcNow,
|
|
SourceExecutionId: writerExecutionId));
|
|
|
|
var published = subscriber.FishForMessage<AttributeValueChanged>(
|
|
m => m.AttributeName == "Status", TimeSpan.FromSeconds(10));
|
|
Assert.Equal(writerExecutionId, published.SourceExecutionId);
|
|
}
|
|
|
|
[Fact]
|
|
public void InstanceActor_WriteWithNoAuditedOrigin_PublishesNoExecutionId()
|
|
{
|
|
var streamManager = new SiteStreamManager(
|
|
new SiteRuntimeOptions { StreamBufferSize = 100 },
|
|
NullLogger<SiteStreamManager>.Instance);
|
|
streamManager.Initialize(Sys);
|
|
|
|
var config = new FlattenedConfiguration
|
|
{
|
|
InstanceUniqueName = "Pump2",
|
|
Attributes =
|
|
[
|
|
new ResolvedAttribute { CanonicalName = "Status", Value = "Normal", DataType = "String" }
|
|
]
|
|
};
|
|
|
|
var instance = ActorOf(Props.Create(() => new InstanceActor(
|
|
"Pump2",
|
|
JsonSerializer.Serialize(config),
|
|
_storage,
|
|
_compilationService,
|
|
_sharedLibrary,
|
|
streamManager,
|
|
_options,
|
|
NullLogger<InstanceActor>.Instance)));
|
|
|
|
var subscriber = CreateTestProbe();
|
|
streamManager.Subscribe("Pump2", subscriber.Ref);
|
|
|
|
instance.Tell(new SetStaticAttributeCommand(
|
|
"corr-2", "Pump2", "Status", "Critical", DateTimeOffset.UtcNow));
|
|
|
|
var published = subscriber.FishForMessage<AttributeValueChanged>(
|
|
m => m.AttributeName == "Status", TimeSpan.FromSeconds(10));
|
|
Assert.Null(published.SourceExecutionId);
|
|
}
|
|
}
|