Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTraceTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

201 lines
9.2 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
{
/// <summary>The audit events captured so far.</summary>
public List<AuditEvent> Events { get; } = new();
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (Events) Events.Add(evt);
return Task.CompletedTask;
}
/// <summary>Polls the captured events until one with the given action appears or the timeout elapses.</summary>
/// <param name="action">The audit action to wait for.</param>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>The first matching event, or <see langword="null"/> if none arrived in time.</returns>
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;
/// <summary>Creates the shared script library, options, scheduler and service provider used by every test.</summary>
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();
}
/// <inheritdoc />
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)));
}
/// <summary>Verifies that a traced call replies with the execution ID and a ScriptRun trace event.</summary>
[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)));
}
/// <summary>Verifies that a traced call whose script fails returns a failed ScriptRun trace event.</summary>
[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);
}
/// <summary>Verifies that an untraced call replies with no trace events but still carries the execution ID.</summary>
[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);
}
/// <summary>Verifies that a traced value change makes the triggered run a child of the source execution ID.</summary>
/// <param name="inferred">Whether the change is an inferred device update rather than a direct write.</param>
/// <param name="triggerSource">The trigger source label stamped on the change.</param>
[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);
}
/// <summary>Verifies that an untraced value change leaves the triggered run as a root and emits no trace events.</summary>
[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);
}
}