186 lines
7.8 KiB
C#
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);
|
|
}
|
|
}
|