Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTraceTests.cs
T
2026-09-09 22:56:36 -04:00

186 lines
7.8 KiB
C#

using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.Audit;
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.Debugging;
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.Actors;
/// <summary>
/// Flow Debugger design 2026-09-05 §5.1/§5.4: a traced <see cref="ScriptCallRequest"/> makes
/// the launched run emit a <c>ScriptRun</c> row and return its <c>ExecutionId</c> plus the
/// inline trace events on the <see cref="ScriptCallResult"/>; a traced, stamped
/// <see cref="AttributeValueChanged"/> parents the value-change run to the writer. Untraced
/// paths are pinned unchanged.
/// </summary>
public class ScriptActorTraceTests : TestKit, IDisposable
{
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;
}
public AuditEvent? WaitForKind(string action, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
lock (Events)
{
var hit = Events.FirstOrDefault(e => e.Action == action);
if (hit is not null) return hit;
}
Thread.Sleep(25);
}
return null;
}
}
private readonly SharedScriptLibrary _sharedLibrary;
private readonly SiteRuntimeOptions _options;
private readonly ScriptExecutionScheduler _scheduler;
private readonly CapturingAuditWriter _writer = new();
private readonly IServiceProvider _services;
public ScriptActorTraceTests()
{
_sharedLibrary = new SharedScriptLibrary(
new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
NullLogger<SharedScriptLibrary>.Instance);
_options = new SiteRuntimeOptions { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = 30 };
_scheduler = new ScriptExecutionScheduler(_options.ScriptExecutionThreadCount);
var sc = new ServiceCollection();
sc.AddSingleton<IAuditWriter>(_writer);
_services = sc.BuildServiceProvider();
}
void IDisposable.Dispose()
{
Shutdown();
_scheduler.Dispose();
}
private static Script<object?> Compile(string code)
{
var script = CSharpScript.Create<object?>(code,
ScriptOptions.Default.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks"),
typeof(ScriptGlobals));
script.Compile();
return script;
}
private IActorRef CreateActor(string name, string code, string? triggerJson = null)
{
var config = new ResolvedScript { CanonicalName = name, Code = code, TriggerType = triggerJson is null ? null : "valuechange", TriggerConfiguration = triggerJson };
return ActorOf(Props.Create(() => new ScriptActor(
name, "Pump1", CreateTestProbe().Ref, Compile(code), config, _sharedLibrary, _options,
NullLogger<ScriptActor>.Instance, null, null, null, _services, _scheduler)));
}
[Fact]
public void Call_WithTrace_ReplyCarriesExecutionId_AndAScriptRunEvent()
{
var actor = CreateActor("Add", "(int)Parameters[\"x\"] + 1");
actor.Tell(new ScriptCallRequest("Add", new Dictionary<string, object?> { ["x"] = 41 }, 0, "cid", Trace: true));
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
Assert.Equal(42, result.ReturnValue);
Assert.NotNull(result.ExecutionId);
var evt = Assert.Single(result.TraceEvents!);
var row = evt.AsRow();
Assert.Equal("ScriptTrace.ScriptRun", evt.Action);
Assert.Equal("Delivered", row.Status.ToString());
Assert.Equal(result.ExecutionId, row.ExecutionId);
Assert.Equal("Add", evt.Target);
Assert.Equal("Pump1", row.SourceInstanceId);
Assert.Contains("\"x\":41", row.RequestSummary);
Assert.Equal("42", row.ResponseSummary);
Assert.Equal("call", ScriptTraceExtra.TryParse(row.Extra)!.TriggerKind);
// The same row went through the audit writer.
Assert.NotNull(_writer.WaitForKind("ScriptTrace.ScriptRun", TimeSpan.FromSeconds(5)));
}
[Fact]
public void Call_WithTrace_FailedRun_ReturnsFailedScriptRunEvent()
{
var actor = CreateActor("Boom", "throw new InvalidOperationException(\"kaboom\");");
actor.Tell(new ScriptCallRequest("Boom", null, 0, "cid", Trace: true));
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.NotNull(result.ExecutionId);
var row = Assert.Single(result.TraceEvents!).AsRow();
Assert.Equal("Failed", row.Status.ToString());
Assert.Contains("kaboom", row.ErrorMessage);
}
[Fact]
public void Call_WithoutTrace_ReplyHasNoTraceEvents_ButStillCarriesExecutionId()
{
var actor = CreateActor("Answer", "42");
actor.Tell(new ScriptCallRequest("Answer", null, 0, "cid"));
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.True(result.Success);
Assert.NotNull(result.ExecutionId);
Assert.Null(result.TraceEvents);
Thread.Sleep(200);
Assert.Empty(_writer.Events);
}
[Theory]
[InlineData(false, "value-change")]
[InlineData(true, "inferred-device-update")]
public void ValueChangeTrigger_TracedChange_RunParentsToSourceExecutionId(bool inferred, string triggerSource)
{
var actor = CreateActor("OnStatus", "1", "{\"attribute\":\"Status\"}");
var writer = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
actor.Tell(new AttributeValueChanged("Pump1", "Status", "Status", "Running", "Good", now, SourceExecutionId: writer, Trace: true, SourceExecutionInferred: inferred));
var evt = _writer.WaitForKind("ScriptTrace.ScriptRun", TimeSpan.FromSeconds(10));
Assert.NotNull(evt);
var row = evt!.AsRow();
Assert.Equal(writer, row.ParentExecutionId);
Assert.NotEqual(writer, row.ExecutionId);
var extra = ScriptTraceExtra.TryParse(row.Extra)!;
Assert.Equal("valuechange", extra.TriggerKind);
Assert.Equal("Status", extra.TriggerAttribute);
Assert.Equal("Running", extra.TriggerValue);
Assert.Equal(triggerSource, extra.TriggerSource);
}
[Fact]
public void ValueChangeTrigger_UntracedChange_RunStaysRoot_AndEmitsNothing()
{
var actor = CreateActor("OnStatus", "1", "{\"attribute\":\"Status\"}");
// Stamped but NOT traced (an ordinary script write) — and a plain DCL change.
actor.Tell(new AttributeValueChanged("Pump1", "Status", "Status", "Running", "Good", DateTimeOffset.UtcNow, SourceExecutionId: Guid.NewGuid()));
actor.Tell(new AttributeValueChanged("Pump1", "Status", "Status", "Stopped", "Good", DateTimeOffset.UtcNow));
Assert.Null(_writer.WaitForKind("ScriptTrace.ScriptRun", TimeSpan.FromSeconds(1)));
Assert.Empty(_writer.Events);
}
}