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()); } }