using System.Security.Claims;
using System.Text.Json;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
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.ScriptAnalysis;
using FlowDebuggerPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.FlowDebugger;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
///
/// bUnit tests for the Flow Debugger page (design 2026-09-05 §4): render, prediction on
/// method choice, Run with typed inputs, the STANDBY_NODE alert, replay via
/// ?executionId=, and the "site not traced" label.
///
public class FlowDebuggerPageTests : BunitContext
{
private readonly IFlowDebugService _flow = Substitute.For();
private readonly IInboundApiRepository _inboundApi = Substitute.For();
private readonly ITemplateEngineRepository _templates = Substitute.For();
private readonly ISiteRepository _sites = Substitute.For();
private readonly IDialogService _dialog = Substitute.For();
private readonly ISchemaLibraryQueryService _schemaLibrary = Substitute.For();
private static readonly ApiMethod Delmia = new("DelmiaRecipeDownload", "return 1;")
{
Id = 4,
ParameterDefinitions = """{"type":"object","properties":{"MachineCode":{"type":"string"},"Qty":{"type":"integer"}},"required":["MachineCode"]}""",
};
private static PredictedFlow ThreeNodePrediction() => new(
[
new("n1", FlowStepKind.InboundRequest, FlowLane.Central, "POST /api/DelmiaRecipeDownload", false, null),
new("n2", FlowStepKind.RouteCall, FlowLane.Central, "Route.To(code)", true, null),
new("n3", FlowStepKind.Response, FlowLane.Central, "Response", false, null),
],
[new("n1", "n2", false), new("n2", "n3", false)]);
private static FlowDebugOutcome GraphOutcome(bool withSite, bool withChildId)
{
var root = Guid.NewGuid();
var child = Guid.NewGuid();
var t0 = DateTime.UtcNow;
var events = new List
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100,
requestSummary: "{}", responseSummary: "{}"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90,
extra: withChildId ? new ScriptTraceExtra { ChildExecutionId = child }.ToJson() : null),
};
if (withSite)
events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80));
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
}
private void RegisterServices(string role = "Deployer", IEnumerable? extraClaims = null)
{
JSInterop.Mode = JSRuntimeMode.Loose;
_inboundApi.GetAllApiMethodsAsync(Arg.Any())
.Returns(Task.FromResult>([Delmia]));
_inboundApi.GetApiMethodByIdAsync(4, Arg.Any()).Returns(Delmia);
_sites.GetAllSitesAsync(Arg.Any())
.Returns(Task.FromResult>([new Site("Plant A", "site-a") { Id = 1 }]));
_flow.PredictMethodAsync(4, Arg.Any()).Returns(ThreeNodePrediction());
Services.AddSingleton(_flow);
Services.AddSingleton(_inboundApi);
Services.AddSingleton(_templates);
Services.AddSingleton(_sites);
_dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
_dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
Services.AddSingleton(_dialog);
// The script entry's Inputs card renders the shared ParameterValueForm, which injects
// the schema library to resolve {"$ref":"lib:Name"} nodes. Empty map = no refs.
_schemaLibrary.GetSchemaMapAsync(Arg.Any()).Returns(new Dictionary());
Services.AddSingleton(_schemaLibrary);
var identity = new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "deployer"), new Claim(ClaimTypes.Role, role), .. extraClaims ?? []], "TestCookie");
var stubAuth = new StubAuthStateProvider(new AuthenticationState(new ClaimsPrincipal(identity)));
Services.AddSingleton(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
Services.AddAuthorizationCore();
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task GetAuthenticationStateAsync() => Task.FromResult(_state);
}
[Fact]
public void Renders_ForDeployer_WithToolbarAndEmptyFlow()
{
RegisterServices();
var cut = Render();
Assert.Contains("Flow Debugger", cut.Markup);
Assert.NotNull(cut.Find("[data-test='flow-toolbar']"));
Assert.NotNull(cut.Find("[data-test='flow-graph-empty']"));
Assert.Equal("Not run", cut.Find("[data-test='flow-state']").TextContent.Trim());
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
// arch-review F3: a Deployer gets the whole surface — entry point, both modes, Run.
Assert.NotNull(cut.Find("button[data-test='flow-entry-method']"));
Assert.NotNull(cut.Find("button[data-test='flow-mode-live']"));
Assert.NotNull(cut.Find("button[data-test='flow-mode-replay']"));
}
// ---- F3: Replay opens to the OperationalAudit roles; Live stays Deployer-only ----------------
[Fact]
public void Renders_ForViewer_ReplayOnly_WithNoLiveControls()
{
// A Viewer holds OperationalAudit but not Deployer: the page opens (it is the
// Audit Log / Execution Chain drill-in target) pinned to Replay, with no way to
// start a Live run.
RegisterServices(role: "Viewer");
var cut = Render();
Assert.NotNull(cut.Find("[data-test='flow-toolbar']"));
Assert.NotNull(cut.Find("input[data-test='flow-execution-id']"));
Assert.NotNull(cut.Find("button[data-test='flow-load']"));
Assert.NotNull(cut.Find("[data-test='flow-replay-only']"));
Assert.Empty(cut.FindAll("button[data-test='flow-run']"));
Assert.Empty(cut.FindAll("button[data-test='flow-mode-live']"));
Assert.Empty(cut.FindAll("button[data-test='flow-entry-method']"));
Assert.Empty(cut.FindAll("button[data-test='flow-entry-script']"));
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
}
[Fact]
public void Viewer_LoadsAReplay_ButGetsNoRetryOrDiscardOnAParkedNode()
{
// Retry / Discard relay a command to the site — a Deployer act. The auditor sees
// the Parked node and its detail, but not the two action buttons.
RegisterServices(role: "Viewer");
var root = Guid.NewGuid();
var (parked, _, _) = ParkedThenResolved(root);
_flow.ReplayAsync(root, Arg.Any()).Returns(parked);
var nav = Services.GetRequiredService();
nav.NavigateTo($"/deployment/flow-debugger?executionId={root}");
var cut = Render();
cut.Instance.Delay = (_, _) => Task.CompletedTask;
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
cut.Find("g[data-test='flow-node'][data-kind='ApiCall']").Click();
Assert.Empty(cut.FindAll("button[data-test='flow-step-retry']"));
Assert.Empty(cut.FindAll("button[data-test='flow-step-discard']"));
_flow.Received(1).ReplayAsync(root, Arg.Any());
}
[Fact]
public void Viewer_DrillInWithAMethodId_DoesNotOpenTheLiveRunSurface()
{
// ?methodId= is a Live-run drill-in. For a non-Deployer it must not put the page
// into Live mode or surface the Run button.
RegisterServices(role: "Viewer");
var nav = Services.GetRequiredService();
nav.NavigateTo("/deployment/flow-debugger?methodId=4");
var cut = Render();
Assert.Empty(cut.FindAll("button[data-test='flow-run']"));
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
Assert.NotNull(cut.Find("input[data-test='flow-execution-id']"));
}
[Fact]
public void ChoosingAMethod_ShowsThePredictedGraph_AndTheInputsForm()
{
RegisterServices();
var cut = Render();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("g[data-test='flow-node']").Count));
Assert.Equal("Predicted", cut.Find("[data-test='flow-state']").TextContent.Trim());
Assert.NotNull(cut.Find("[data-test='flow-predicted-note']"));
Assert.NotNull(cut.Find("input[data-test='flow-input-MachineCode']"));
Assert.NotNull(cut.Find("input[data-test='flow-input-Qty']"));
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
[Fact]
public void Run_SendsTypedInputs_AndRendersTheReturnedGraph()
{
RegisterServices();
JsonElement? sent = null;
_flow.RunMethodAsync(4, Arg.Do(p => sent = p), Arg.Any())
.Returns(GraphOutcome(withSite: true, withChildId: true));
var cut = Render();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
cut.Find("input[data-test='flow-input-MachineCode']").Input("Z28061");
cut.Find("input[data-test='flow-input-Qty']").Input("3");
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
Assert.NotNull(sent);
Assert.Equal("Z28061", sent!.Value.GetProperty("MachineCode").GetString());
Assert.Equal(JsonValueKind.Number, sent.Value.GetProperty("Qty").ValueKind);
Assert.Equal(3, sent.Value.GetProperty("Qty").GetInt32());
// 3 events + synthesized response = 4 real steps; the prediction's unmatched nodes ghost.
Assert.Equal("4", cut.Find("[data-test='flow-step-count']").TextContent.Trim());
Assert.True(cut.FindAll("g[data-test='flow-node']").Count >= 4);
Assert.NotNull(cut.Find("[data-test='flow-root-execution']"));
// The first step is auto-selected into the detail panel.
Assert.Contains("POST /api/DelmiaRecipeDownload", cut.Find("[data-test='flow-step-title']").TextContent);
}
[Fact]
public void Run_StandbyNodeRefusal_ShowsInlineAlertVerbatim()
{
RegisterServices();
_flow.RunMethodAsync(4, Arg.Any(), Arg.Any())
.Returns(FlowDebugOutcome.Fail("This node is standby; run the flow on the active central node.", "STANDBY_NODE"));
var cut = Render();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
cut.Find("button[data-test='flow-run']").Click();
var alert = cut.WaitForElement("[data-test='flow-error']");
Assert.Equal("STANDBY_NODE", alert.GetAttribute("data-code"));
Assert.Contains("This node is standby; run the flow on the active central node.", alert.TextContent);
Assert.Contains("alert-warning", alert.ClassName);
Assert.Equal("Failed", cut.Find("[data-test='flow-state']").TextContent.Trim());
}
[Fact]
public void ExecutionIdQueryParameter_TriggersReplayOnFirstRender()
{
RegisterServices();
var exec = Guid.NewGuid();
_flow.ReplayAsync(exec, Arg.Any()).Returns(GraphOutcome(withSite: true, withChildId: true));
var nav = Services.GetRequiredService();
nav.NavigateTo($"/deployment/flow-debugger?executionId={exec}");
var cut = Render();
cut.WaitForAssertion(() => _flow.Received(1).ReplayAsync(exec, Arg.Any()));
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
Assert.Equal(exec.ToString(), cut.Find("input[data-test='flow-execution-id']").GetAttribute("value"));
Assert.Contains("btn-primary", cut.Find("button[data-test='flow-mode-replay']").ClassName);
}
[Fact]
public void Run_WithNoSiteStepsAndNoChildId_ShowsSiteNotTracedLabel()
{
RegisterServices();
_flow.RunMethodAsync(4, Arg.Any(), Arg.Any())
.Returns(GraphOutcome(withSite: false, withChildId: false));
var cut = Render();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForElement("[data-test='flow-site-not-traced']");
}
// ---- Node actions (follow-ups plan 2026-09-06, Task 8): Retry / Discard on a Parked node -------
/// Same events as but the cached call ends Parked; the second
/// outcome shares the event objects (stable ids, as real audit rows are) and adds a Delivered resolve.
private static (FlowDebugOutcome Parked, FlowDebugOutcome Resolved, Guid StepId) ParkedThenResolved(Guid root)
{
var child = Guid.NewGuid();
var corr = Guid.NewGuid();
var t0 = DateTime.UtcNow.AddSeconds(-10);
var submit = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
occurredAtUtc: t0.AddMilliseconds(3), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01");
var events = new List
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100, responseSummary: "{}"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90,
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
submit,
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Parked,
occurredAtUtc: t0.AddSeconds(4), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", errorMessage: "503 Service Unavailable"),
};
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
var parked = new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
var later = new List(events)
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered,
occurredAtUtc: t0.AddSeconds(9), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01"),
};
var resolved = new FlowDebugOutcome(FlowGraphBuilder.Build(later), raw with { Events = later }, null, null);
return (parked, resolved, submit.EventId);
}
private IRenderedComponent RunToParkedNode(Guid root, FlowDebugOutcome parked)
{
_flow.RunMethodAsync(4, Arg.Any(), Arg.Any()).Returns(parked);
var cut = Render();
cut.Instance.Delay = (_, _) => Task.CompletedTask;
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
cut.Find("g[data-test='flow-node'][data-kind='ApiCall']").Click();
cut.WaitForElement("button[data-test='flow-step-retry']");
return cut;
}
[Fact]
public void ParkedNode_Retry_ConfirmsRelaysAndRepollsUntilNotParked()
{
RegisterServices();
var root = Guid.NewGuid();
var (parked, resolved, stepId) = ParkedThenResolved(root);
_flow.RetryOutboundAsync(Arg.Any(), Arg.Any())
.Returns(new FlowOutboundActionResult(true, "Retry of abc relayed to site-a."));
_flow.ReplayAsync(root, Arg.Any()).Returns(parked, resolved);
var cut = RunToParkedNode(root, parked);
_flow.DidNotReceive().ReplayAsync(Arg.Any(), Arg.Any()); // Parked is not Pending: no auto-poll
cut.Find("button[data-test='flow-step-retry']").Click();
cut.WaitForAssertion(() => _flow.Received(1).RetryOutboundAsync(Arg.Is(s => s.EventId == stepId), Arg.Any()));
_dialog.Received(1).ConfirmAsync("Retry cached call", Arg.Is(m => m.Contains("Delmia.Ack")), Arg.Any(), Arg.Any());
cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any()));
cut.WaitForAssertion(() => Assert.Contains(cut.FindAll("[data-test='flow-node-pill']"), p => p.TextContent.Trim() == "Ok"));
Assert.Contains("relayed", cut.Markup);
Assert.Empty(cut.FindAll("button[data-test='flow-step-retry']")); // the selected node is no longer parked
}
[Fact]
public void ParkedNode_Discard_UsesDangerConfirm_AndDoesNotPollWhenRefused()
{
RegisterServices();
var root = Guid.NewGuid();
var (parked, _, _) = ParkedThenResolved(root);
_flow.DiscardOutboundAsync(Arg.Any(), Arg.Any())
.Returns(new FlowOutboundActionResult(false, "Site unreachable — the relay did not reach the owning site."));
var cut = RunToParkedNode(root, parked);
cut.Find("button[data-test='flow-step-discard']").Click();
cut.WaitForAssertion(() => _flow.Received(1).DiscardOutboundAsync(Arg.Any(), Arg.Any()));
_dialog.Received(1).ConfirmAsync("Discard cached call", Arg.Is(m => m.Contains("cannot be undone")), true, "Discard");
cut.WaitForAssertion(() => Assert.Contains("Site unreachable", cut.Markup));
_flow.DidNotReceive().ReplayAsync(Arg.Any(), Arg.Any());
Assert.NotNull(cut.Find("button[data-test='flow-step-retry']"));
}
[Fact]
public void ParkedNode_ConfirmDeclined_NeverRelays()
{
RegisterServices();
_dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(false);
var root = Guid.NewGuid();
var (parked, _, _) = ParkedThenResolved(root);
var cut = RunToParkedNode(root, parked);
cut.Find("button[data-test='flow-step-retry']").Click();
cut.WaitForAssertion(() => _dialog.Received(1).ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()));
_flow.DidNotReceive().RetryOutboundAsync(Arg.Any(), Arg.Any());
_flow.DidNotReceive().ReplayAsync(Arg.Any(), Arg.Any());
}
[Fact]
public void ParkedNode_OutOfScopeSite_RefusedBeforeConfirm()
{
// Deployer scoped to site 2 only; the parked call belongs to site-a (id 1).
RegisterServices(extraClaims: [new Claim(JwtTokenService.SiteIdClaimType, "2")]);
var root = Guid.NewGuid();
var (parked, _, _) = ParkedThenResolved(root);
var cut = RunToParkedNode(root, parked);
cut.Find("button[data-test='flow-step-retry']").Click();
cut.WaitForAssertion(() => Assert.Contains("not permitted", cut.Markup));
_dialog.DidNotReceive().ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
_flow.DidNotReceive().RetryOutboundAsync(Arg.Any(), Arg.Any());
}
// ---- Outbound calls (2026-09-06 plan, Task 9): pending refresh after a Live run ----------
///
/// Inbound → route → site run → cached API call; the call is Pending unless .
/// Both outcomes of one share their rows (same event/correlation ids), as the
/// real system does: the inline Live rows ARE the audit rows Replay later reads back.
///
private static FlowDebugOutcome OutboundOutcome(Guid root, bool resolved)
{
var (events, child, corr, t0) = OutboundBase.GetOrAdd(root, r =>
{
var child = Guid.NewGuid();
var corr = Guid.NewGuid();
var t0 = DateTime.UtcNow.AddSeconds(-10);
return (BuildOutboundBase(r, child, corr, t0), child, corr, t0);
});
events = new List(events);
if (resolved)
events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered,
occurredAtUtc: t0.AddSeconds(4), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01"));
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
}
private static readonly System.Collections.Concurrent.ConcurrentDictionary Events, Guid Child, Guid Corr, DateTime T0)> OutboundBase = new();
private static List BuildOutboundBase(Guid root, Guid child, Guid corr, DateTime t0)
{
return new List
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100, responseSummary: "{}"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90,
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
occurredAtUtc: t0.AddMilliseconds(3), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01"),
};
}
[Fact]
public void LiveRun_WithPendingOutbound_PollsReplayUntilTerminal()
{
RegisterServices();
var root = Guid.NewGuid();
_flow.RunMethodAsync(4, Arg.Any(), Arg.Any()).Returns(OutboundOutcome(root, resolved: false));
_flow.ReplayAsync(root, Arg.Any()).Returns(OutboundOutcome(root, resolved: false), OutboundOutcome(root, resolved: true));
var cut = Render();
cut.Instance.Delay = (_, _) => Task.CompletedTask;
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any()));
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='flow-pending-count']")));
Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim());
Assert.Empty(cut.FindAll("[data-test='flow-pending-note']"));
Assert.Contains(cut.FindAll("[data-test='flow-node-pill']"), p => p.TextContent.Trim() == "Ok");
Assert.NotNull(cut.Find("button[data-test='flow-refresh']"));
}
///
/// Live run whose inline site rows already resolved a cached DB write, with a notification still
/// Pending; =false models the Replay read taken before that DB
/// telemetry reached central (observed live on the rig 2026-09-06: cached-call telemetry landed
/// 12 s after the run, the first poll at 5 s had none of it).
///
private static FlowDebugOutcome DbAndNotifyOutcome(Guid root, Guid child, Guid dbCorr, Guid notifyCorr, DateTime t0, bool withDbRows, bool notifyDelivered)
{
var events = new List
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: t0, target: "POST /api/FlowOutboundDemo", executionId: root, durationMs: 100, responseSummary: "{}"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.OutboundDemo", executionId: root, durationMs: 90,
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(2), target: "OutboundDemo", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted,
occurredAtUtc: t0.AddMilliseconds(10), target: "FlowDemo", correlationId: notifyCorr, executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01"),
};
if (withDbRows)
{
events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
occurredAtUtc: t0.AddMilliseconds(3), target: "RigDb", correlationId: dbCorr, executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01"));
events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.CachedResolve, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(5), target: "RigDb", correlationId: dbCorr, executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01"));
}
if (notifyDelivered)
events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifyDeliver, AuditStatus.Delivered,
occurredAtUtc: t0.AddSeconds(1), target: "FlowDemo", correlationId: notifyCorr, executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01"));
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
}
[Fact]
public void PendingRefresh_KeepsStepsTheLiveRunAlreadyHad_WhenReplayLagsBehindTelemetry()
{
RegisterServices();
var root = Guid.NewGuid();
var child = Guid.NewGuid();
var dbCorr = Guid.NewGuid();
var notifyCorr = Guid.NewGuid();
var t0 = DateTime.UtcNow.AddSeconds(-10);
var live = DbAndNotifyOutcome(root, child, dbCorr, notifyCorr, t0, withDbRows: true, notifyDelivered: false);
var lagging = DbAndNotifyOutcome(root, child, dbCorr, notifyCorr, t0, withDbRows: false, notifyDelivered: true);
// Sanity: the two graphs disagree exactly on the DB node.
Assert.Contains(live.Graph!.Steps, s => s.Kind == FlowStepKind.DbCall);
Assert.DoesNotContain(lagging.Graph!.Steps, s => s.Kind == FlowStepKind.DbCall);
_flow.RunMethodAsync(4, Arg.Any(), Arg.Any()).Returns(live);
_flow.ReplayAsync(root, Arg.Any()).Returns(lagging);
var cut = Render();
cut.Instance.Delay = (_, _) => Task.CompletedTask;
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
cut.Find("button[data-test='flow-run']").Click();
// The poll resolves the notification (nothing Pending → polling stops) ...
cut.WaitForAssertion(() => _flow.Received(1).ReplayAsync(root, Arg.Any()));
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='flow-pending-count']")));
// ... but must NOT drop the DB node the live run already drew: a refresh only ever adds rows.
Assert.NotEmpty(cut.FindAll("g[data-test='flow-node'][data-kind='DbCall']"));
Assert.NotEmpty(cut.FindAll("g[data-test='flow-node'][data-kind='Notify']"));
Assert.Contains(cut.FindAll("[data-test='flow-node-pill']"), p => p.TextContent.Trim() == "Ok");
}
[Fact]
public void LiveRun_WithNoPendingStep_NeverPolls()
{
RegisterServices();
var root = Guid.NewGuid();
_flow.RunMethodAsync(4, Arg.Any(), Arg.Any()).Returns(OutboundOutcome(root, resolved: true));
var cut = Render();
cut.Instance.Delay = (_, _) => Task.CompletedTask;
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
_flow.DidNotReceive().ReplayAsync(Arg.Any(), Arg.Any());
Assert.Empty(cut.FindAll("[data-test='flow-pending-count']"));
}
[Fact]
public void ReplayMode_NeverPolls_ButRefreshButtonReplaysOnce()
{
RegisterServices();
var root = Guid.NewGuid();
_flow.ReplayAsync(root, Arg.Any()).Returns(OutboundOutcome(root, resolved: false));
var nav = Services.GetRequiredService();
nav.NavigateTo($"/deployment/flow-debugger?executionId={root}");
var cut = Render();
cut.Instance.Delay = (_, _) => Task.CompletedTask;
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
Assert.Equal("1", cut.Find("[data-test='flow-pending-count']").TextContent.Trim());
_flow.Received(1).ReplayAsync(root, Arg.Any());
cut.Find("button[data-test='flow-refresh']").Click();
cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any()));
Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim());
}
// ---- Script entry (design 2026-09-08 §5): site → instance → script ----------------------
private static readonly FlowScriptOption OnFlag = new("OnFlag", "ValueChange", "Flag", null, null);
private static readonly FlowScriptOption Manual = new("Manual", "Call", null,
"""{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20);
private static readonly FlowScriptOption Tick = new("Tick", "Interval", null, null, null);
///
/// Picks an instance through the InstancePicker combobox that replaced the flat
/// instance <select> (design 2026-09-08): open the panel, click the row. The
/// fixture instance has no area, so its row sits at the root and needs no expansion.
///
private static void PickInstance(IRenderedComponent cut, int id)
{
cut.Find("[data-test='flow-instance-toggle']").Click();
cut.Find($"[data-test='flow-instance-row'][data-instance-id='{id}']").Click();
}
/// Clicks the picker row (an area label toggles it, an instance label selects it).
private static void ClickPickerRow(IRenderedComponent cut, string label) =>
cut.FindAll("[data-test='flow-instance-row']").First(r => r.TextContent.Trim() == label).Click();
private void WireInstance()
{
_templates.GetInstancesBySiteIdAsync(1, Arg.Any())
.Returns(Task.FromResult>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
_templates.GetAreasBySiteIdAsync(1, Arg.Any())
.Returns(Task.FromResult>([]));
_templates.GetInstanceByIdAsync(7, Arg.Any()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 });
_flow.GetInstanceScriptsAsync(7, Arg.Any()).Returns(Task.FromResult>([Manual, OnFlag, Tick]));
_flow.PredictScriptAsync(7, Arg.Any(), Arg.Any()).Returns(ThreeNodePrediction());
}
[Fact]
public void ScriptEntry_ListsScriptsWithTriggerLabels_AfterSiteAndInstance()
{
RegisterServices();
WireInstance();
var cut = Render();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
PickInstance(cut, 7);
var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList();
Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options);
Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']"));
}
[Fact]
public void ScriptEntry_WriteKind_ShowsTheValueField_ForTheTriggerAttribute_AndGatesRunOnIt()
{
RegisterServices();
WireInstance();
var cut = Render();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
PickInstance(cut, 7);
cut.Find("select[data-test='flow-script-select']").Change("OnFlag");
Assert.Contains("Flag", cut.Find("[data-test='flow-inputs']").TextContent);
Assert.Empty(cut.FindAll("[data-test='flow-script-params']"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
cut.Find("input[data-test='flow-input-value']").Input("true");
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
[Fact]
public void ScriptEntry_CallKind_ShowsTheParameterForm_AndRunsWithParameters()
{
RegisterServices();
WireInstance();
_flow.RunScriptAsync(7, "Manual", Arg.Any(), null, Arg.Any()).Returns(GraphOutcome(true, true));
var cut = Render();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
PickInstance(cut, 7);
cut.Find("select[data-test='flow-script-select']").Change("Manual");
Assert.NotEmpty(cut.FindAll("[data-test='flow-script-params']"));
Assert.Empty(cut.FindAll("input[data-test='flow-input-value']"));
cut.Find("#param-n").Input("3");
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
_ = _flow.Received(1).RunScriptAsync(7, "Manual", Arg.Is(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any());
}
[Fact]
public void ScriptEntry_CallKindWithoutParameters_SaysSo_AndCanRun()
{
RegisterServices();
WireInstance();
var cut = Render();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
PickInstance(cut, 7);
cut.Find("select[data-test='flow-script-select']").Change("Tick");
Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent);
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
[Fact]
public void DeepLink_InstanceAndScript_PreselectsAndPredicts()
{
RegisterServices();
WireInstance();
var nav = Services.GetRequiredService();
nav.NavigateTo("/deployment/flow-debugger?instanceId=7&script=Manual");
var cut = Render();
cut.WaitForAssertion(() => Assert.Equal("Manual", cut.Find("select[data-test='flow-script-select']").GetAttribute("value")));
_ = _flow.Received().PredictScriptAsync(7, "Manual", Arg.Any());
}
///
/// Two call-kind scripts declaring BYTE-IDENTICAL parameter definitions — the case that makes
/// ParameterValueForm skip its reparse (and its ValidChanged) and keeps the previous
/// script's raw text and parse errors. The page must remount the form per script instead.
///
private static readonly FlowScriptOption Manual2 = new("Manual2", "Call", null,
"""{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20);
private void WireInstanceWithTwinScripts()
{
_templates.GetInstancesBySiteIdAsync(1, Arg.Any())
.Returns(Task.FromResult>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
_flow.GetInstanceScriptsAsync(7, Arg.Any())
.Returns(Task.FromResult>([Manual, Manual2, Tick]));
_flow.PredictScriptAsync(7, Arg.Any(), Arg.Any()).Returns(ThreeNodePrediction());
}
private IRenderedComponent ChooseScriptWithAnInvalidParameter()
{
var cut = Render();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
PickInstance(cut, 7);
cut.Find("select[data-test='flow-script-select']").Change("Manual");
cut.Find("#param-n").Input("abc"); // not an integer: the form parks a parse error
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
return cut;
}
[Fact]
public void ScriptEntry_SwitchingBetweenCallKindScripts_ResetsTheFormAndValidity()
{
RegisterServices();
WireInstanceWithTwinScripts();
var cut = ChooseScriptWithAnInvalidParameter();
cut.Find("select[data-test='flow-script-select']").Change("Manual2");
// A fresh form: no echo of the previous script's text, and no stale parse error keeping
// Run disabled invisibly — Run is disabled only because the required field is unset.
Assert.Equal(string.Empty, cut.Find("#param-n").GetAttribute("value"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
cut.Find("#param-n").Input("5");
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
[Fact]
public void ScriptEntry_SwitchingFromAnInvalidFormToANoParameterScript_EnablesRun()
{
RegisterServices();
WireInstanceWithTwinScripts();
var cut = ChooseScriptWithAnInvalidParameter();
cut.Find("select[data-test='flow-script-select']").Change("Tick");
Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent);
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
[Fact]
public void ScriptEntry_InstancePicker_ShowsAreasCollapsed_UntilExpanded()
{
RegisterServices();
WireInstance();
// Same instance, now filed under Line1 / Cell A so the picker has a tree to drill into.
_templates.GetAreasBySiteIdAsync(1, Arg.Any())
.Returns(Task.FromResult>(
[new Area("Line1") { Id = 1, SiteId = 1 }, new Area("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }]));
_templates.GetInstancesBySiteIdAsync(1, Arg.Any())
.Returns(Task.FromResult>(
[new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10, AreaId = 2 }]));
var cut = Render();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
cut.Find("[data-test='flow-instance-toggle']").Click();
// The panel opens collapsed: only the root area, and the instance is not rendered at all.
Assert.Equal(["Line1"], cut.FindAll("[data-test='flow-instance-row']").Select(r => r.TextContent.Trim()).ToList());
Assert.Empty(cut.FindAll("[data-test='flow-instance-row'][data-instance-id='7']"));
ClickPickerRow(cut, "Line1");
Assert.Empty(cut.FindAll("[data-test='flow-instance-row'][data-instance-id='7']"));
ClickPickerRow(cut, "Cell A");
cut.Find("[data-test='flow-instance-row'][data-instance-id='7']").Click();
// Picking the row ran OnInstanceChangedAsync, so the script list is loaded.
var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList();
Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options);
}
[Fact]
public void ScriptEntry_DeepLink_InstanceId_ShowsItsNameInThePicker()
{
RegisterServices();
WireInstance();
var nav = Services.GetRequiredService();
nav.NavigateTo("/deployment/flow-debugger?instanceId=7");
var cut = Render();
cut.WaitForAssertion(() =>
Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value")));
}
[Fact]
public void ScriptEntry_SiteChange_ClearsThePickedInstance()
{
RegisterServices();
WireInstance();
// A second site the operator can switch to, with no instances of its own.
_sites.GetAllSitesAsync(Arg.Any()).Returns(Task.FromResult>(
[new Site("Plant A", "site-a") { Id = 1 }, new Site("Plant B", "site-b") { Id = 2 }]));
_templates.GetInstancesBySiteIdAsync(2, Arg.Any())
.Returns(Task.FromResult>([]));
_templates.GetAreasBySiteIdAsync(2, Arg.Any())
.Returns(Task.FromResult>([]));
var cut = Render();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
PickInstance(cut, 7);
Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value"));
cut.Find("select[data-test='flow-site-select']").Change("2");
// The pick is dropped on both sides: the page's id and the picker's field text, which
// it re-derives because the new site handed it a different instance list.
Assert.Equal(string.Empty, cut.Find("[data-test='flow-instance-input']").GetAttribute("value"));
Assert.Equal(["— choose —"], cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList());
}
}