using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// /// Flow Debugger design 2026-09-05 §5.1/§5.2: a traced /// emits ScriptTrace rows for attribute writes and waits (into the inline collector /// AND the audit writer), stamps Trace on the commands it sends, and inherits the flag /// into nested calls. An untraced context is byte-for-byte the pre-feature behaviour. /// public class ScriptRuntimeContextTraceEmissionTests : TestKit { private const string InstanceName = "Plant.Pump42"; private sealed class CapturingAuditWriter : IAuditWriter { public List Events { get; } = new(); public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (Events) Events.Add(evt); return Task.CompletedTask; } } private static SharedScriptLibrary NewLibrary() => new(new ScriptCompilationService(NullLogger.Instance), NullLogger.Instance); private static ScriptRuntimeContext CreateContext( IActorRef instanceActor, IAuditWriter? writer, bool traced, FlowTraceCollector? collector, Guid? executionId = null, Guid? parentExecutionId = null) => new( instanceActor, NewLibrary(), currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5), instanceName: InstanceName, logger: NullLogger.Instance, siteId: "site-a", sourceScript: "ScriptActor:A", auditWriter: writer, executionId: executionId, parentExecutionId: parentExecutionId, sourceNode: "node-a", isTraced: traced, traceCollector: collector); [Fact] public async Task SetAttribute_WhenTraced_EmitsAttributeWriteRow_AndPassesTraceOnCommand() { var probe = CreateTestProbe(); var writer = new CapturingAuditWriter(); var collector = new FlowTraceCollector(); var exec = Guid.NewGuid(); var parent = Guid.NewGuid(); var ctx = CreateContext(probe.Ref, writer, traced: true, collector, exec, parent); var call = ctx.SetAttribute("Setpoint", "42"); var cmd = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(cmd.Trace); Assert.Equal(exec, cmd.SourceExecutionId); probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow)); await call; var evt = Assert.Single(writer.Events); var row = evt.AsRow(); Assert.Equal("ScriptTrace", evt.Category); Assert.Equal("ScriptTrace.AttributeWrite", evt.Action); Assert.Equal(exec, row.ExecutionId); Assert.Equal(parent, row.ParentExecutionId); Assert.Equal("site-a", row.SourceSiteId); Assert.Equal("node-a", evt.SourceNode); Assert.Equal("Setpoint", evt.Target); Assert.Equal(AuditOutcome.Success, evt.Outcome); var extra = ScriptTraceExtra.TryParse(row.Extra); var write = Assert.Single(extra!.Writes!); Assert.Equal(("Setpoint", "42", true), (write.Attribute, write.Written, write.Success)); Assert.Contains("\"value\":\"42\"", row.RequestSummary); Assert.Same(evt, Assert.Single(collector.Snapshot())); } [Fact] public async Task SetAttribute_WhenNotTraced_EmitsNothing_AndCommandTraceFalse() { var probe = CreateTestProbe(); var writer = new CapturingAuditWriter(); var ctx = CreateContext(probe.Ref, writer, traced: false, collector: null); var call = ctx.SetAttribute("Setpoint", "42"); var cmd = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.False(cmd.Trace); probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow)); await call; Assert.Empty(writer.Events); Assert.False(ctx.IsTraced); } [Fact] public async Task SetAttribute_WhenTraced_FailedWrite_EmitsFailedRow_ThenThrows() { var probe = CreateTestProbe(); var writer = new CapturingAuditWriter(); var ctx = CreateContext(probe.Ref, writer, traced: true, new FlowTraceCollector()); var call = ctx.SetAttribute("Setpoint", "42"); var cmd = probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", false, "device rejected", DateTimeOffset.UtcNow)); await Assert.ThrowsAsync(() => call); var row = Assert.Single(writer.Events).AsRow(); Assert.Equal("Failed", row.Status.ToString()); Assert.Equal("device rejected", row.ErrorMessage); } [Fact] public async Task WaitInternal_WhenTraced_EmitsAttributeWait_WithOutcomeSatisfied() { var probe = CreateTestProbe(); var writer = new CapturingAuditWriter(); var collector = new FlowTraceCollector(); var ctx = CreateContext(probe.Ref, writer, traced: true, collector); var wait = ctx.WaitAttributeFull("Processed", "true", null, TimeSpan.FromSeconds(3)); var req = probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new WaitForAttributeResponse(req.CorrelationId, Matched: true, true, "Good", TimedOut: false)); var result = await wait; Assert.True(result.Matched); var evt = Assert.Single(writer.Events); Assert.Equal("ScriptTrace.AttributeWait", evt.Action); Assert.Equal("Processed", evt.Target); var extra = ScriptTraceExtra.TryParse(evt.AsRow().Extra)!; Assert.Equal("satisfied", extra.WaitOutcome); Assert.Equal("Processed", extra.WaitAttribute); Assert.Equal("true", extra.WaitTarget); Assert.Equal(3000, extra.WaitTimeoutMs); Assert.Equal("true", extra.ObservedValue, ignoreCase: true); Assert.Single(collector.Snapshot()); } [Fact] public async Task WaitInternal_WhenTraced_Timeout_EmitsFailedRow_WithTimeoutOutcome() { var probe = CreateTestProbe(); var writer = new CapturingAuditWriter(); var ctx = CreateContext(probe.Ref, writer, traced: true, new FlowTraceCollector()); var wait = ctx.WaitAttribute("Processed", "true", null, TimeSpan.FromSeconds(1)); var req = probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new WaitForAttributeResponse(req.CorrelationId, Matched: false, null, null, TimedOut: true)); Assert.False(await wait); var row = Assert.Single(writer.Events).AsRow(); Assert.Equal("Failed", row.Status.ToString()); Assert.Contains("timed out", row.ErrorMessage); Assert.Equal("timeout", ScriptTraceExtra.TryParse(row.Extra)!.WaitOutcome); } [Fact] public async Task WaitInternal_WhenNotTraced_EmitsNothing() { var probe = CreateTestProbe(); var writer = new CapturingAuditWriter(); var ctx = CreateContext(probe.Ref, writer, traced: false, collector: null); var wait = ctx.WaitAttribute("Processed", "true", null, TimeSpan.FromSeconds(1)); var req = probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new WaitForAttributeResponse(req.CorrelationId, Matched: true, true, "Good", TimedOut: false)); await wait; Assert.Empty(writer.Events); } [Fact] public async Task CallScript_WhenTraced_PropagatesTraceOnScriptCallRequest() { var probe = CreateTestProbe(); var ctx = CreateContext(probe.Ref, null, traced: true, new FlowTraceCollector()); var call = ctx.CallScript("B"); var req = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(req.Trace); probe.Reply(new ScriptCallResult(req.CorrelationId, true, null, null)); await call; // The inline shared-script child context inherits the flag too. Assert.True(ctx.CreateChildContextForSharedScript(1).IsTraced); Assert.False(CreateContext(probe.Ref, null, traced: false, null).CreateChildContextForSharedScript(1).IsTraced); } [Fact] public async Task CallScript_WhenNotTraced_RequestTraceIsFalse() { var probe = CreateTestProbe(); var ctx = CreateContext(probe.Ref, null, traced: false, null); var call = ctx.CallScript("B"); var req = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.False(req.Trace); probe.Reply(new ScriptCallResult(req.CorrelationId, true, null, null)); await call; } }