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; /// /// Audit Log #23 (M5.4 T4 — ParentExecutionId 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. /// /// The cascade is three hops, each pinned here: /// /// /// ScriptRuntimeContext.SetAttribute stamps the running script's /// ExecutionId onto . /// /// /// The Instance Actor carries that id onto the published /// . /// /// /// The Alarm Actor hands it to the spawned AlarmExecutionActor as the /// on-trigger run's ParentExecutionId — observable via /// AlarmActor.LastOnTriggerParentExecutionId. /// /// /// /// /// A value arriving from the Data Connection Layer (external device data) carries /// no SourceExecutionId, so the alarm it fires is correctly parentless. /// /// 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.Instance); _sharedLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); _options = new SiteRuntimeOptions(); _localDb = TestLocalDb.CreateTemp("alarm-cascade-test"); _storage = new SiteStorageService(_localDb.Db, NullLogger.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); } /// A trivially-succeeding on-trigger script, so the alarm actually spawns an execution. private Script OnTriggerScript() { var compiled = _compilationService.Compile("OnTrigger", "return null;"); Assert.NotNull(compiled.CompiledScript); return compiled.CompiledScript!; } /// /// Compiles a trigger expression outside the trust validator (mirrors /// AlarmActorTests.CompileRawTriggerExpression). /// private static Script 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(expression, opts, typeof(TriggerExpressionGlobals)); script.Compile(); return script; } /// /// Builds an Alarm Actor as a TestActorRef so the spawn-time /// ParentExecutionId can be read off the underlying actor (the spawned /// child builds its ScriptRuntimeContext internally and exposes nothing). /// private TestActorRef BuildAlarm( ResolvedAlarm config, out Akka.TestKit.TestProbe instanceProbe, Script? triggerExpression = null) { var probe = CreateTestProbe(); instanceProbe = probe; return ActorOfAsTestActorRef( Props.Create(() => new AlarmActor( config.CanonicalName, "Pump1", probe.Ref, config, OnTriggerScript(), _sharedLibrary, _options, NullLogger.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(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(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(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(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.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.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( 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.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.Instance))); var subscriber = CreateTestProbe(); streamManager.Subscribe("Pump2", subscriber.Ref); instance.Tell(new SetStaticAttributeCommand( "corr-2", "Pump2", "Status", "Critical", DateTimeOffset.UtcNow)); var published = subscriber.FishForMessage( m => m.AttributeName == "Status", TimeSpan.FromSeconds(10)); Assert.Null(published.SourceExecutionId); } }