using System.Security.Claims;
using System.Text.Json;
using Akka.Actor;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
///
/// Tests for (Flow Debugger design 2026-09-05 §5.5/§7.1):
/// the management-command bridge (holder null, error mapping, success → graph), the
/// prediction resolver wiring over substituted repositories, the script entry (design
/// 2026-09-08 §5 — script list, prediction and run over the flattened configuration), and
/// the Retry/Discard relay for a parked outbound node (follow-ups plan 2026-09-06 D4).
///
///
/// is a concrete class with non-virtual methods, so the
/// relay tests wire its two central-local proxies (SetSiteCallAudit /
/// SetNotificationOutbox ) to scripted s on the test's
/// — the same seam SiteCallsReportPageTests and
/// NotificationReportPageTests use.
///
public sealed class FlowDebugServiceTests : IDisposable
{
private static readonly JsonSerializerOptions ServerJson = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
};
private readonly ActorSystem _system = ActorSystem.Create("flow-debug-service-tests");
private readonly IInboundApiRepository _inboundApi = Substitute.For();
private readonly ITemplateEngineRepository _templates = Substitute.For();
private readonly IFlatteningPipeline _pipeline = Substitute.For();
private readonly List _received = new();
// Relay probes: everything the scripted Site Call Audit / Notification Outbox actors receive.
private readonly List _siteCallRequests = new();
private readonly List _outboxRequests = new();
// Scripted relay replies — overridable per test before the relay call.
private SiteCallRelayOutcome _siteCallOutcome = SiteCallRelayOutcome.Applied;
private bool _siteReachable = true;
private string? _siteCallError;
public void Dispose() => _system.Terminate().Wait(TimeSpan.FromSeconds(5));
private FlowDebugService CreateService(object? reply, string role = "Deployer")
{
var holder = new ManagementActorHolder();
if (reply != null)
holder.ActorRef = _system.ActorOf(Props.Create(() => new ScriptedManagementActor(reply, _received)));
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, role),
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger.Instance);
comms.SetSiteCallAudit(_system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))));
comms.SetNotificationOutbox(_system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))));
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, _pipeline, comms, NullLogger.Instance);
}
///
/// Wires the instance row plus the flattened configuration its scripts are read from
/// (script entry, design 2026-09-08 §2 — the picker and the prediction both go through
/// , never the raw template chain).
///
private void WireFlattened(int instanceId, string uniqueName, params ResolvedScript[] scripts)
{
_templates.GetInstanceByIdAsync(instanceId, Arg.Any())
.Returns(new Instance(uniqueName) { Id = instanceId, TemplateId = 10, SiteId = 1 });
var cfg = new FlattenedConfiguration { InstanceUniqueName = uniqueName, TemplateId = 10, SiteId = 1, Scripts = scripts };
_pipeline.FlattenAndValidateAsync(instanceId, Arg.Any(), false, null)
.Returns(Result.Success(new FlatteningPipelineResult(cfg, "h", new ValidationResult())));
}
/// An External-lane step as FlowStepProjector would project it (positional per ).
private static FlowStep OutboundStep(FlowStepKind kind, FlowStepOutcome outcome, Guid? correlationId, string? siteId)
=> new(
Guid.NewGuid(), Guid.NewGuid(), null, kind, FlowLane.External,
"ERP.GetOrder", null, DateTimeOffset.UtcNow, null, outcome,
null, null, null, siteId, "node-a", "Sim01", "ERP.GetOrder", null,
Lifecycle: null, CorrelationId: correlationId);
[Fact]
public async Task RetryOutbound_ApiCallParked_RelaysRetrySiteCallWithCorrelationAndSite()
{
var c = Guid.NewGuid();
var svc = CreateService(reply: null);
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, c, "site-a"));
Assert.True(result.Success, result.Message);
Assert.Contains("relayed", result.Message);
Assert.Contains("site-a", result.Message);
var request = Assert.IsType(Assert.Single(_siteCallRequests));
Assert.Equal(c, request.TrackedOperationId);
Assert.Equal("site-a", request.SourceSite);
Assert.Equal("tester", request.RequestedBy);
Assert.Empty(_outboxRequests);
}
[Fact]
public async Task RetryOutbound_Notify_RelaysRetryNotification()
{
var c = Guid.NewGuid();
var svc = CreateService(reply: null);
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.Notify, FlowStepOutcome.Parked, c, siteId: null));
Assert.True(result.Success, result.Message);
Assert.Contains("re-queued", result.Message);
var request = Assert.IsType(Assert.Single(_outboxRequests));
Assert.Equal(c.ToString("N"), request.NotificationId);
Assert.Equal("tester", request.RequestedBy);
Assert.Empty(_siteCallRequests);
}
[Fact]
public async Task DiscardOutbound_DbCall_RelaysDiscardSiteCall()
{
var c = Guid.NewGuid();
var svc = CreateService(reply: null);
var result = await svc.DiscardOutboundAsync(OutboundStep(FlowStepKind.DbCall, FlowStepOutcome.Parked, c, "site-b"));
Assert.True(result.Success, result.Message);
Assert.Contains("Discard of", result.Message);
Assert.Contains("site-b", result.Message);
var request = Assert.IsType(Assert.Single(_siteCallRequests));
Assert.Equal(c, request.TrackedOperationId);
Assert.Equal("site-b", request.SourceSite);
Assert.Equal("tester", request.RequestedBy);
}
[Fact]
public async Task RetryOutbound_NotParked_RefusesWithoutRelay()
{
var svc = CreateService(reply: null);
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Ok, Guid.NewGuid(), "site-a"));
Assert.False(result.Success);
Assert.Contains("not parked", result.Message);
Assert.Empty(_siteCallRequests);
Assert.Empty(_outboxRequests);
}
[Fact]
public async Task RetryOutbound_NoCorrelation_Refuses()
{
var svc = CreateService(reply: null);
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, correlationId: null, "site-a"));
Assert.False(result.Success);
Assert.Contains("no tracked operation id", result.Message);
Assert.Empty(_siteCallRequests);
Assert.Empty(_outboxRequests);
}
[Fact]
public async Task RetryOutbound_SiteKindWithoutSiteId_Refuses()
{
var svc = CreateService(reply: null);
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.DbCall, FlowStepOutcome.Parked, Guid.NewGuid(), siteId: " "));
Assert.False(result.Success);
Assert.Contains("owning site", result.Message);
Assert.Empty(_siteCallRequests);
Assert.Empty(_outboxRequests);
}
[Fact]
public async Task RetryOutbound_NonOutboundStep_Refuses()
{
var svc = CreateService(reply: null);
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ScriptRun, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a"));
Assert.False(result.Success);
Assert.Contains("Only an API call", result.Message);
Assert.Empty(_siteCallRequests);
Assert.Empty(_outboxRequests);
}
[Fact]
public async Task RetryOutbound_SiteUnreachable_MapsToFailureMessage()
{
_siteCallOutcome = SiteCallRelayOutcome.SiteUnreachable;
_siteReachable = false;
var svc = CreateService(reply: null);
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a"));
Assert.False(result.Success);
Assert.Contains("unreachable", result.Message);
Assert.Single(_siteCallRequests);
}
[Fact]
public async Task RetryOutbound_NotParkedAtSite_MapsToFailureMessage()
{
_siteCallOutcome = SiteCallRelayOutcome.NotParked;
var svc = CreateService(reply: null);
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a"));
Assert.False(result.Success);
Assert.Contains("no longer parked", result.Message);
}
[Fact]
public async Task DiscardOutbound_OperationFailed_UsesTheSitesOwnErrorMessage()
{
_siteCallOutcome = SiteCallRelayOutcome.OperationFailed;
_siteCallError = "S&F buffer is locked by a sweep.";
var svc = CreateService(reply: null);
var result = await svc.DiscardOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a"));
Assert.False(result.Success);
Assert.Equal("S&F buffer is locked by a sweep.", result.Message);
Assert.IsType(Assert.Single(_siteCallRequests));
}
private static string SuccessPayload(int events, bool completed = true, bool truncated = false)
{
var root = Guid.NewGuid();
var child = Guid.NewGuid();
var t0 = DateTime.UtcNow;
var list = new List
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: t0, target: "POST /api/M", executionId: root, durationMs: 100,
requestSummary: """{"MachineCode":"Z28061"}""", responseSummary: """{"Result":true}"""),
};
if (events > 1)
list.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(1), target: "I.S", executionId: root, durationMs: 90,
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()));
if (events > 2)
list.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(2), target: "S", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "I", durationMs: 80));
var result = new FlowDebugRunResult(root, completed, null, null, list, truncated, t0, t0.AddSeconds(1));
return JsonSerializer.Serialize(result, ServerJson);
}
[Fact]
public async Task RunMethod_HolderNull_ReportsServiceUnavailable()
{
var svc = CreateService(reply: null);
var outcome = await svc.RunMethodAsync(4, null);
Assert.False(outcome.Success);
Assert.Null(outcome.Graph);
Assert.Equal("SERVICE_UNAVAILABLE", outcome.ErrorCode);
Assert.Empty(_received);
}
[Fact]
public async Task RunMethod_ManagementError_MapsCodeAndMessageVerbatim()
{
var svc = CreateService(new ManagementError("c", "This node is standby.", "STANDBY_NODE"));
var outcome = await svc.RunMethodAsync(4, null);
Assert.False(outcome.Success);
Assert.Equal("STANDBY_NODE", outcome.ErrorCode);
Assert.Equal("This node is standby.", outcome.Error);
}
[Fact]
public async Task RunMethod_Unauthorized_MapsToUnauthorizedCode()
{
var svc = CreateService(new ManagementUnauthorized("c", "Deployer role required."), role: "Viewer");
var outcome = await svc.RunMethodAsync(4, null);
Assert.Equal("UNAUTHORIZED", outcome.ErrorCode);
Assert.Equal("Deployer role required.", outcome.Error);
}
[Fact]
public async Task RunMethod_Success_BuildsGraphWithTheReturnedSteps_AndSendsTheTypedCommandForThePrincipal()
{
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 3)));
using var doc = JsonDocument.Parse("""{"MachineCode":"Z28061"}""");
var outcome = await svc.RunMethodAsync(4, doc.RootElement.Clone());
Assert.True(outcome.Success);
Assert.NotNull(outcome.Graph);
Assert.NotNull(outcome.Raw);
Assert.True(outcome.Raw!.Completed);
// 3 returned events + the synthesized Response node.
Assert.Equal(4, outcome.Graph!.Steps.Count);
Assert.Contains(outcome.Graph.Steps, s => s.Kind == FlowStepKind.ScriptRun && s.Lane == FlowLane.Site);
var envelope = Assert.Single(_received);
var cmd = Assert.IsType(envelope.Command);
Assert.Equal(4, cmd.ApiMethodId);
Assert.Equal("Z28061", cmd.Parameters!.Value.GetProperty("MachineCode").GetString());
Assert.Equal("tester", envelope.User.Username);
Assert.Contains("Deployer", envelope.User.Roles);
}
[Theory]
[InlineData(180, 205)]
[InlineData(1, 30)]
public void RunAskTimeoutFor_IsMethodTimeoutPlusRunGracePlusClientSlack_FlooredAtMinRunAskTimeout(int timeoutSeconds, int expectedSeconds)
{
var timeout = FlowDebugService.RunAskTimeoutFor(timeoutSeconds);
Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), timeout);
}
[Fact]
public void ValueChangeAskTimeoutFor_IsWaitSecondsPlusClientSlack()
{
var timeout = FlowDebugService.ValueChangeAskTimeoutFor(45);
Assert.Equal(TimeSpan.FromSeconds(60), timeout);
}
[Fact]
public async Task RunWriteKindScript_AndReplay_SendTheirCommands()
{
WireFlattened(7, "Sim01",
new ResolvedScript { CanonicalName = "OnSpeed", Code = "x", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Speed\"}" });
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 1)));
var exec = Guid.NewGuid();
var vc = await svc.RunScriptAsync(7, "OnSpeed", null, "42");
var rp = await svc.ReplayAsync(exec);
Assert.True(vc.Success);
Assert.True(rp.Success);
Assert.Equal(2, _received.Count);
var vcCmd = Assert.IsType(_received[0].Command);
Assert.Equal((7, "OnSpeed", "42"), (vcCmd.InstanceId, vcCmd.ScriptName, vcCmd.Value));
// A write kind fires through the attribute, so it carries no parameters.
Assert.Null(vcCmd.Parameters);
var rpCmd = Assert.IsType(_received[1].Command);
Assert.Equal(exec, rpCmd.ExecutionId);
}
[Fact]
public async Task Run_TruncatedResult_FlagsTheGraph()
{
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 2, truncated: true)));
var outcome = await svc.RunMethodAsync(4, null);
Assert.True(outcome.Success);
Assert.True(outcome.Graph!.Truncated);
}
[Fact]
public async Task PredictMethod_ResolvesConstantRouteTargets_ThroughInstanceTemplateChain()
{
_inboundApi.GetApiMethodByIdAsync(4, Arg.Any())
.Returns(new ApiMethod("Delmia", """return await Route.To("Sim01").Call("Process", new { });""") { Id = 4 });
_templates.GetInstanceByUniqueNameAsync("Sim01", Arg.Any())
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 20, SiteId = 1 });
// Derived template 20 has no scripts of its own; its base 10 defines Process.
_templates.GetScriptsByTemplateIdAsync(20, Arg.Any())
.Returns(Task.FromResult>([]));
_templates.GetTemplateByIdAsync(20, Arg.Any())
.Returns(new Template("Derived") { Id = 20, ParentTemplateId = 10 });
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any())
.Returns(Task.FromResult>(
[
new TemplateScript("Process", """await Attributes.SetAsync("Cmd", 1); await Attributes.WaitAsync("Ack", true, System.TimeSpan.FromSeconds(1));""") { Id = 1, TemplateId = 10 },
]));
_templates.GetTemplateByIdAsync(10, Arg.Any())
.Returns(new Template("Base") { Id = 10, ParentTemplateId = null });
var svc = CreateService(reply: null);
var flow = await svc.PredictMethodAsync(4);
Assert.Equal(
[
FlowStepKind.InboundRequest, FlowStepKind.RouteCall, FlowStepKind.ScriptRun,
FlowStepKind.AttributeWrite, FlowStepKind.AttributeWait, FlowStepKind.Response,
], flow.Nodes.Select(n => n.Kind).ToList());
Assert.Equal("Sim01.Process", flow.Nodes[2].Title);
Assert.Equal("Sim01.Cmd", flow.Nodes[3].Title);
}
///
/// Regression for the 2026-09-06 circuit deadlock:
/// runs on Blazor Server's single-threaded renderer sync context, and the route-target resolver used
/// to block on the repository with GetAwaiter().GetResult() from inside the synchronous
/// predictor walk. A repository continuation posted back to that context could then never run —
/// the whole circuit hung (every later click / JS-interop reply queued behind the blocked dispatcher).
/// The pump below IS that context: one thread, posts queued, a blocked callback starves the queue.
///
[Fact]
public void PredictMethod_OnASingleThreadedSyncContext_NeverBlocksTheDispatcher()
{
_inboundApi.GetApiMethodByIdAsync(4, Arg.Any())
.Returns(new ApiMethod("Delmia", """return await Route.To("Sim01").Call("Process", new { });""") { Id = 4 });
// The instance lookup completes only after a Yield — i.e. only when the sync context gets to
// run the continuation. Blocking on it from the context's own thread is the deadlock.
_templates.GetInstanceByUniqueNameAsync("Sim01", Arg.Any())
.Returns(_ => YieldThen(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 }));
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any())
.Returns(Task.FromResult>(
[
new TemplateScript("Process", """await Attributes.SetAsync("Cmd", 1);""") { Id = 1, TemplateId = 10 },
]));
_templates.GetTemplateByIdAsync(10, Arg.Any())
.Returns(new Template("Base") { Id = 10, ParentTemplateId = null });
var svc = CreateService(reply: null);
var pump = new SingleThreadPump();
PredictedFlow? flow = null;
Exception? failure = null;
var done = new ManualResetEventSlim();
var thread = new Thread(() =>
{
try { flow = pump.Run(() => svc.PredictMethodAsync(4)); }
catch (Exception ex) { failure = ex; }
finally { done.Set(); }
}) { IsBackground = true, Name = "flow-predict-pump" };
thread.Start();
Assert.True(done.Wait(TimeSpan.FromSeconds(10)),
"PredictMethodAsync deadlocked the single-threaded sync context (sync-over-async in the route resolver).");
Assert.Null(failure);
Assert.NotNull(flow);
Assert.Contains(flow!.Nodes, n => n.Kind == FlowStepKind.ScriptRun && n.Title == "Sim01.Process");
Assert.Contains(flow.Nodes, n => n.Kind == FlowStepKind.AttributeWrite && n.Title == "Sim01.Cmd");
}
[Fact]
public async Task PredictMethod_NestedRouteTargets_ResolveAcrossPasses()
{
// Process calls a second script on the same instance via Scripts.Call — discovered only once
// Process itself has been resolved, so the service needs a second resolve-and-rewalk pass.
_inboundApi.GetApiMethodByIdAsync(4, Arg.Any())
.Returns(new ApiMethod("Delmia", """return await Route.To("Sim01").Call("Process", new { });""") { Id = 4 });
_templates.GetInstanceByUniqueNameAsync("Sim01", Arg.Any())
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any())
.Returns(Task.FromResult>(
[
new TemplateScript("Process", """await Scripts.Call("Inner", new { });""") { Id = 1, TemplateId = 10 },
new TemplateScript("Inner", """await Attributes.SetAsync("Cmd", 1);""") { Id = 2, TemplateId = 10 },
]));
_templates.GetTemplateByIdAsync(10, Arg.Any())
.Returns(new Template("Base") { Id = 10, ParentTemplateId = null });
var svc = CreateService(reply: null);
var flow = await svc.PredictMethodAsync(4);
var kinds = flow.Nodes.Select(n => n.Kind).ToList();
Assert.Contains(FlowStepKind.AttributeWrite, kinds);
Assert.Contains(flow.Nodes, n => n.Title == "Sim01.Inner");
Assert.Contains(flow.Nodes, n => n.Title == "Sim01.Cmd");
}
private static async Task YieldThen(Instance instance)
{
await Task.Yield();
return instance;
}
/// A Blazor-Server-shaped sync context: a single thread draining a queue of posted callbacks.
private sealed class SingleThreadPump : SynchronizationContext
{
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
public override void Send(SendOrPostCallback d, object? state) => throw new NotSupportedException();
public T Run(Func> body)
{
var previous = Current;
SetSynchronizationContext(this);
try
{
var task = body();
while (!task.IsCompleted)
{
var (callback, state) = _queue.Take();
callback(state);
}
return task.GetAwaiter().GetResult();
}
finally
{
SetSynchronizationContext(previous);
}
}
}
[Fact]
public async Task PredictMethod_UnknownMethod_IsEmpty()
{
_inboundApi.GetApiMethodByIdAsync(99, Arg.Any()).Returns((ApiMethod?)null);
var svc = CreateService(reply: null);
var flow = await svc.PredictMethodAsync(99);
Assert.Empty(flow.Nodes);
}
[Fact]
public async Task GetInstanceScripts_ProjectsFlattenedScripts_WithTriggerAttributeAndKind_OrderedByName()
{
WireFlattened(9, "Sim01",
new ResolvedScript { CanonicalName = "Manual", Code = "return 1;", TriggerType = "Call", ParameterDefinitions = "{\"type\":\"object\"}", ExecutionTimeoutSeconds = 12 },
new ResolvedScript { CanonicalName = "OnFlag", Code = "x", TriggerType = "valuechange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" },
new ResolvedScript { CanonicalName = "Mod.Tick", Code = "x", TriggerType = "Interval", TriggerConfiguration = "{\"intervalMs\":1000}" });
var options = await CreateService(reply: null).GetInstanceScriptsAsync(9);
Assert.Equal(["Manual", "Mod.Tick", "OnFlag"], options.Select(o => o.CanonicalName).ToList());
var onFlag = options.Single(o => o.CanonicalName == "OnFlag");
Assert.True(onFlag.IsWriteKind); Assert.Equal("Flag", onFlag.TriggerAttribute); Assert.Equal("valuechange: Flag", onFlag.TriggerLabel);
var manual = options.Single(o => o.CanonicalName == "Manual");
Assert.False(manual.IsWriteKind); Assert.Equal(12, manual.ExecutionTimeoutSeconds); Assert.Equal("Call", manual.TriggerLabel);
Assert.False(options.Single(o => o.CanonicalName == "Mod.Tick").IsWriteKind);
}
[Fact]
public async Task GetInstanceScripts_UnknownInstance_IsEmpty()
{
Assert.Empty(await CreateService(reply: null).GetInstanceScriptsAsync(404));
}
[Fact]
public async Task PredictScript_WriteKind_RootsAtTheWrite_AndAppendsTheScript()
{
WireFlattened(9, "Sim01",
new ResolvedScript { CanonicalName = "OnFlag", Code = """Attributes["Ack"] = true;""", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" });
var flow = await CreateService(reply: null).PredictScriptAsync(9, "OnFlag");
Assert.Equal(
[
(FlowStepKind.AttributeWrite, "Sim01.Flag"),
(FlowStepKind.ScriptRun, "Sim01.OnFlag"),
(FlowStepKind.AttributeWrite, "Ack"),
], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList());
}
[Fact]
public async Task PredictScript_CallKind_RootsAtARouteCall_ThenTheScript()
{
WireFlattened(9, "Sim01",
new ResolvedScript { CanonicalName = "Manual", Code = """Attributes["Ack"] = true;""", TriggerType = "Call" });
var flow = await CreateService(reply: null).PredictScriptAsync(9, "Manual");
Assert.Equal(FlowStepKind.RouteCall, flow.Nodes[0].Kind);
Assert.Equal(FlowLane.Central, flow.Nodes[0].Lane);
Assert.Equal("Sim01.Manual", flow.Nodes[0].Title);
Assert.Equal(FlowStepKind.ScriptRun, flow.Nodes[1].Kind);
Assert.True(flow.Edges[0].CrossesBoundary);
}
[Fact]
public async Task PredictScript_UnknownScript_IsEmpty()
{
WireFlattened(9, "Sim01");
Assert.Same(PredictedFlow.Empty, await CreateService(reply: null).PredictScriptAsync(9, "Nope"));
}
[Fact]
public async Task RunScript_SendsTheCommand_WithTheScriptShape()
{
WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call", ExecutionTimeoutSeconds = 40 });
var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson)));
using var doc = JsonDocument.Parse("""{"n":3}""");
var outcome = await svc.RunScriptAsync(9, "Manual", doc.RootElement.Clone(), null);
Assert.True(outcome.Success, outcome.Error);
var env = Assert.Single(_received);
var cmd = Assert.IsType(env.Command);
Assert.Equal((9, "Manual"), (cmd.InstanceId, cmd.ScriptName));
// A call kind carries its parameters verbatim and no value.
Assert.Equal(3, cmd.Parameters!.Value.GetProperty("n").GetInt32());
Assert.Null(cmd.Value);
}
///
/// The picker, the prediction and the run of one instance share ONE flatten for the life of
/// the scoped service (design 2026-09-08 §5) — a regression here means every keystroke on the
/// page re-walks the template chain.
///
[Fact]
public async Task GetInstanceScripts_ThenPredict_ThenRun_FlattenOnce()
{
WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" });
var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson)));
await svc.GetInstanceScriptsAsync(9);
await svc.PredictScriptAsync(9, "Manual");
await svc.RunScriptAsync(9, "Manual", null, null);
await _pipeline.Received(1).FlattenAndValidateAsync(9, Arg.Any(), false, null);
}
private sealed class StubAuth : AuthenticationStateProvider
{
private readonly ClaimsPrincipal _user;
public StubAuth(ClaimsPrincipal user) => _user = user;
public override Task GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(_user));
}
private sealed class ScriptedManagementActor : ReceiveActor
{
public ScriptedManagementActor(object reply, List received)
{
Receive(env =>
{
lock (received) received.Add(env);
Sender.Tell(reply);
});
}
}
/// Stands in for the Site Call Audit singleton proxy: records the relay request, replies as scripted.
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
public ScriptedSiteCallAuditActor(FlowDebugServiceTests test)
{
Receive(r =>
{
lock (test._siteCallRequests) test._siteCallRequests.Add(r);
Sender.Tell(new RetrySiteCallResponse(r.CorrelationId, test._siteCallOutcome,
test._siteCallOutcome == SiteCallRelayOutcome.Applied, test._siteReachable, test._siteCallError));
});
Receive(r =>
{
lock (test._siteCallRequests) test._siteCallRequests.Add(r);
Sender.Tell(new DiscardSiteCallResponse(r.CorrelationId, test._siteCallOutcome,
test._siteCallOutcome == SiteCallRelayOutcome.Applied, test._siteReachable, test._siteCallError));
});
}
}
/// Stands in for the Notification Outbox singleton proxy: records the request, replies success.
private sealed class ScriptedOutboxActor : ReceiveActor
{
public ScriptedOutboxActor(FlowDebugServiceTests test)
{
Receive(r =>
{
lock (test._outboxRequests) test._outboxRequests.Add(r);
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null));
});
Receive(r =>
{
lock (test._outboxRequests) test._outboxRequests.Add(r);
Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null));
});
}
}
}