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));
}
}