using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using Moq; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// /// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): nested /// CallScript / CallShared invocations and alarm on-trigger runs /// must form a true execution tree, where each spawned run records its /// immediate spawner's ExecutionId as its ParentExecutionId. /// /// /// /// A nested CallScript (actor-routed) emits a /// whose ParentExecutionId is the /// CALLING run's OWN ExecutionId — NOT the inherited grandparent — so /// A → CallScript(B) yields B.Parent == A.ExecutionId. /// /// /// A nested CallShared (inline) runs in a child context that mints a /// fresh ExecutionId and records the caller's ExecutionId as its /// parent — so B → CallShared(C) yields C.Parent == B.ExecutionId /// (and NOT B's inherited parent A), proving a multi-level tree. /// /// /// The alarm on-trigger plumbing carries a parentExecutionId into the /// script context — null today (the run is a root) but threaded so a future /// firing id can flow. /// /// /// public class ParentExecutionTreeTests : TestKit { private const string InstanceName = "Plant.Pump42"; /// /// In-memory capturing every emitted event /// (mirrors ExecutionCorrelationContextTests.CapturingAuditWriter). /// private sealed class CapturingAuditWriter : IAuditWriter { public List Events { get; } = new(); public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt.AsRow()); return Task.CompletedTask; } } private static SharedScriptLibrary NewLibrary() { var compilationService = new ScriptCompilationService( NullLogger.Instance); return new SharedScriptLibrary( compilationService, NullLogger.Instance); } /// /// Builds a context whose CallScript Ask targets /// (a probe), so the forwarded can be captured. /// private static ScriptRuntimeContext CreateContext( IActorRef instanceActor, SharedScriptLibrary library, IExternalSystemClient? externalSystemClient = null, IAuditWriter? auditWriter = null, Guid? executionId = null, Guid? parentExecutionId = null) { return new ScriptRuntimeContext( instanceActor, ActorRefs.Nobody, library, currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5), instanceName: InstanceName, logger: NullLogger.Instance, externalSystemClient: externalSystemClient, siteId: "site-77", sourceScript: "ScriptActor:A", auditWriter: auditWriter, executionId: executionId, parentExecutionId: parentExecutionId); } // ------------------------------------------------------------------------- // Nested CallScript (actor-routed) — A → CallScript(B) // ------------------------------------------------------------------------- [Fact] public async Task CallScript_StampsCallingRunsOwnExecutionId_AsChildParent() { // A → CallScript(B): the child request's ParentExecutionId must be A's // OWN ExecutionId, forming the A→B tree edge. var probe = CreateTestProbe(); var aExecutionId = Guid.NewGuid(); var context = CreateContext(probe.Ref, NewLibrary(), executionId: aExecutionId); var call = context.CallScript("B"); var request = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("B", request.ScriptName); // B's parent is A's own execution id — the A→B tree edge. Assert.Equal(aExecutionId, request.ParentExecutionId); // Unblock the Ask so the test completes cleanly. probe.Reply(new ScriptCallResult(request.CorrelationId, true, null, null)); await call; } [Fact] public async Task CallScript_FromRoutedRun_UsesOwnExecutionId_NotInheritedParent() { // A 2-level tree edge: B was itself spawned (it carries a parent = A). // When B does CallScript(C), C.Parent must be B's OWN ExecutionId — NOT // the inherited A. This is the regression that distinguishes a true tree // from a flattened "everything under the original spawner" model. var probe = CreateTestProbe(); var bExecutionId = Guid.NewGuid(); var aExecutionId = Guid.NewGuid(); // B's inherited parent var context = CreateContext( probe.Ref, NewLibrary(), executionId: bExecutionId, parentExecutionId: aExecutionId); var call = context.CallScript("C"); var request = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(bExecutionId, request.ParentExecutionId); Assert.NotEqual(aExecutionId, request.ParentExecutionId); probe.Reply(new ScriptCallResult(request.CorrelationId, true, null, null)); await call; } // ------------------------------------------------------------------------- // Nested CallShared (inline) — B → CallShared(C) // ------------------------------------------------------------------------- [Fact] public async Task CallShared_ChildRun_ParentIsCallersExecutionId_FreshOwnExecutionId() { // B → CallShared(C): the shared script C runs inline but is modelled as // its OWN execution node — a fresh ExecutionId parented to B's // ExecutionId. Asserted via the audit row C emits through // Instance.ExternalSystem.Call. var client = new Mock(); client .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{}", null)); var writer = new CapturingAuditWriter(); var library = NewLibrary(); Assert.True(library.CompileAndRegister( "C", "await Instance.ExternalSystem.Call(\"ERP\", \"GetOrder\"); return null;")); var bExecutionId = Guid.NewGuid(); var context = CreateContext( ActorRefs.Nobody, library, externalSystemClient: client.Object, auditWriter: writer, executionId: bExecutionId); await context.Scripts.CallShared("C"); var evt = Assert.Single(writer.Events); // C's parent is B's execution id — the B→C tree edge. Assert.Equal(bExecutionId, evt.ParentExecutionId); // C minted its OWN fresh, non-empty execution id, distinct from B. Assert.NotNull(evt.ExecutionId); Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); Assert.NotEqual(bExecutionId, evt.ExecutionId!.Value); } [Fact] public async Task CallShared_FromRoutedRun_ChildParentIsCaller_NotInheritedGrandparent() { // Regression / multi-level: B itself carries a parent A. When B does // CallShared(C), C.Parent must be B's OWN ExecutionId — NOT A. This is // the A→B→C chain proving each level points at its immediate spawner. var client = new Mock(); client .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{}", null)); var writer = new CapturingAuditWriter(); var library = NewLibrary(); Assert.True(library.CompileAndRegister( "C", "await Instance.ExternalSystem.Call(\"ERP\", \"GetOrder\"); return null;")); var bExecutionId = Guid.NewGuid(); var aExecutionId = Guid.NewGuid(); // B's inherited parent var context = CreateContext( ActorRefs.Nobody, library, externalSystemClient: client.Object, auditWriter: writer, executionId: bExecutionId, parentExecutionId: aExecutionId); await context.Scripts.CallShared("C"); var evt = Assert.Single(writer.Events); Assert.Equal(bExecutionId, evt.ParentExecutionId); Assert.NotEqual(aExecutionId, evt.ParentExecutionId); } // ------------------------------------------------------------------------- // Alarm on-trigger plumbing // ------------------------------------------------------------------------- [Fact] public void CreateChildContextForSharedScript_ParentIsCallerExecution_FreshOwnId() { // Unit-level proof of the child-context contract the CallShared path uses. var bExecutionId = Guid.NewGuid(); var context = CreateContext( ActorRefs.Nobody, NewLibrary(), executionId: bExecutionId); var child = context.CreateChildContextForSharedScript(childCallDepth: 1); Assert.Equal(bExecutionId, child.ParentExecutionId); Assert.NotEqual(Guid.Empty, child.ExecutionId); Assert.NotEqual(bExecutionId, child.ExecutionId); } [Fact] public void AlarmOnTrigger_NestedCallScript_CarriesAlarmRunsOwnExecutionId_AsParent() { // End-to-end alarm plumbing: when an alarm fires, its on-trigger script // runs in a ScriptRuntimeContext built by AlarmExecutionActor. With no // Guid firing id today the alarm run is a ROOT (its own ParentExecutionId // is null), but it still mints its OWN fresh ExecutionId. A nested // CallScript from that on-trigger script must therefore carry the alarm // run's OWN (non-null) ExecutionId as the child's ParentExecutionId — // proving the alarm context is a proper execution node feeding the // cascade and the parentExecutionId parameter is plumbed end-to-end. var compilationService = new ScriptCompilationService( NullLogger.Instance); var sharedLibrary = new SharedScriptLibrary( compilationService, NullLogger.Instance); var options = new SiteRuntimeOptions(); var onTrigger = compilationService.Compile( "OnTrigger", "await Instance.CallScript(\"Child\"); return null;"); Assert.NotNull(onTrigger.CompiledScript); 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, onTrigger.CompiledScript, sharedLibrary, options, NullLogger.Instance))); alarm.Tell(new AttributeValueChanged( "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow)); // The alarm raises (instance gets AlarmStateChanged) AND the on-trigger // script fires its nested CallScript at the instance. instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); var request = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("Child", request.ScriptName); // The alarm run is a root today (its own parent is null), but its OWN // freshly-minted ExecutionId cascades to the child — so the child's // ParentExecutionId is a real, non-empty value, NOT null. Assert.NotNull(request.ParentExecutionId); Assert.NotEqual(Guid.Empty, request.ParentExecutionId!.Value); instanceProbe.Reply(new ScriptCallResult(request.CorrelationId, true, null, null)); } }