93489cd7a2
Flow Debugger (design 2026-09-05 §5.1/§5.2): the context is the single funnel for SetAttribute / WriteBatchAndWait / WaitInternal, so it emits the write/wait rows (collector first, then the best-effort IAuditWriter whose redactor applies the payload cap), stamps Trace on SetStaticAttributeCommand and ScriptCallRequest, and hands the flag + sink to the inline CallShared child context. Untraced contexts emit nothing (pinned). Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
207 lines
9.1 KiB
C#
207 lines
9.1 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Flow Debugger design 2026-09-05 §5.1/§5.2: a traced <see cref="ScriptRuntimeContext"/>
|
|
/// emits <c>ScriptTrace</c> rows for attribute writes and waits (into the inline collector
|
|
/// AND the audit writer), stamps <c>Trace</c> on the commands it sends, and inherits the flag
|
|
/// into nested calls. An untraced context is byte-for-byte the pre-feature behaviour.
|
|
/// </summary>
|
|
public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
|
{
|
|
private const string InstanceName = "Plant.Pump42";
|
|
|
|
private sealed class CapturingAuditWriter : IAuditWriter
|
|
{
|
|
public List<AuditEvent> 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<ScriptCompilationService>.Instance),
|
|
NullLogger<SharedScriptLibrary>.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<SetStaticAttributeCommand>(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<SetStaticAttributeCommand>(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<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
|
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", false, "device rejected", DateTimeOffset.UtcNow));
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<WaitForAttributeRequest>(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<WaitForAttributeRequest>(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<WaitForAttributeRequest>(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<ScriptCallRequest>(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<ScriptCallRequest>(TimeSpan.FromSeconds(5));
|
|
Assert.False(req.Trace);
|
|
probe.Reply(new ScriptCallResult(req.CorrelationId, true, null, null));
|
|
await call;
|
|
}
|
|
}
|