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"]}""", }; /// /// #37: the MesMoveOut shape — a REQUIRED array of objects — which the old flat text-box pane /// could not take at all (the array reached the site empty). /// private static readonly ApiMethod MesMoveOut = new("MesMoveOut", "return 1;") { Id = 5, ParameterDefinitions = """ {"type":"object","properties":{ "SAPID":{"type":"string"}, "WorkOrders":{"type":"array","items":{"type":"object","properties":{ "WorkOrderNumber":{"type":"string"},"PartNumber":{"type":"string"}}, "required":["WorkOrderNumber"]}}}, "required":["SAPID","WorkOrders"]} """, }; 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)]); [Fact] public void RefreshMerge_UsesReplayOrderThenLiveOnlyRows() { var first = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered); var late = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered); var last = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered); var liveOnly = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered); var result = FlowDebuggerPage.MergeEvents([first, last, liveOnly], [first, late, last]); Assert.Equal(new[] { first.EventId, late.EventId, last.EventId, liveOnly.EventId }, result.Select(e => e.EventId)); } 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, MesMoveOut])); _inboundApi.GetApiMethodByIdAsync(4, Arg.Any()).Returns(Delmia); _inboundApi.GetApiMethodByIdAsync(5, Arg.Any()).Returns(MesMoveOut); _sites.GetAllSitesAsync(Arg.Any()) .Returns(Task.FromResult>([new Site("Plant A", "site-a") { Id = 1 }])); _flow.PredictMethodAsync(4, Arg.Any()).Returns(ThreeNodePrediction()); _flow.PredictMethodAsync(5, 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(); Services.AddTestConfigurationMutations(); } 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']")); // #37: the method pane is the shared ParameterValueForm (typed inputs by field id), and // Run is gated on its validity — MachineCode is required, so Run stays off until it is set. Assert.NotEmpty(cut.FindAll("[data-test='flow-method-params']")); Assert.NotNull(cut.Find("#param-MachineCode")); Assert.NotNull(cut.Find("#param-Qty")); Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); cut.Find("#param-MachineCode").Input("Z28061"); cut.WaitForAssertion(() => 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"); FillRequiredMethodInput(cut); cut.Find("#param-MachineCode").Input("Z28061"); cut.Find("#param-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"); FillRequiredMethodInput(cut); 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"); FillRequiredMethodInput(cut); 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"); FillRequiredMethodInput(cut); 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"); FillRequiredMethodInput(cut); 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"); FillRequiredMethodInput(cut); 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"); FillRequiredMethodInput(cut); 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"))); } /// /// The same deep link, but the instance sits two areas deep. The picker resolves the field /// text from SelectedInstanceId against the flat instance list, not by walking the /// tree — so a nested instance must show its name with the panel never opened (and therefore /// with Line1 / Cell A never expanded). /// [Fact] public void ScriptEntry_DeepLink_InstanceIdInsideAnArea_ShowsItsNameWithoutOpeningThePanel() { RegisterServices(); WireInstance(); _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 }])); var nested = new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10, AreaId = 2 }; _templates.GetInstancesBySiteIdAsync(1, Arg.Any()) .Returns(Task.FromResult>([nested])); _templates.GetInstanceByIdAsync(7, Arg.Any()).Returns(nested); 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"))); // The panel was never opened, so nothing of the tree is rendered. Assert.Empty(cut.FindAll("[data-test='flow-instance-panel']")); Assert.Empty(cut.FindAll("[data-test='flow-instance-row']")); } /// /// The picker must stay disabled for the WHOLE site load, not just until the site id lands. /// Blazor renders as soon as OnSiteChangedAsync hits its first await, and at that point /// _selectedSiteId is already non-zero while _instances/_areas are still the /// previous site's lists — so an operator (or Playwright's FillAsync, which waits only for /// "enabled") could type into the field and have the load wipe the text and close the panel a /// moment later, because the picker reads a new list object as "the site changed". /// [Fact] public void ScriptEntry_PickerStaysDisabled_UntilTheSitesInstancesHaveLoaded() { RegisterServices(); WireInstance(); // Hold the instance load open so the render that follows the first await is observable. var gate = new TaskCompletionSource>(); _templates.GetInstancesBySiteIdAsync(1, Arg.Any()).Returns(gate.Task); var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); Assert.True(cut.Find("[data-test='flow-instance-input']").HasAttribute("disabled")); Assert.True(cut.Find("[data-test='flow-instance-toggle']").HasAttribute("disabled")); cut.InvokeAsync(() => gate.SetResult([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }])); cut.WaitForAssertion(() => Assert.False(cut.Find("[data-test='flow-instance-input']").HasAttribute("disabled"))); } [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()); } // ---- Step logs (plan 2026-09-09 D5/D7, Task 13): status-strip counts, truncation notes, cross-step hint ---- /// One ScriptTrace.ScriptLog row — a line the builder folds onto a step, never a node of its own. private static AuditEvent LogLine(Guid execution, string level, int ms, Guid? stepEventId = null, string message = "hello") => ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered, occurredAtUtc: DateTime.UtcNow.AddMilliseconds(ms), actor: "Process", target: "Process", sourceNode: "node-a", executionId: execution, sourceSiteId: "site-a", sourceInstanceId: "Sim01", requestSummary: message, extra: new ScriptTraceExtra { LogLevel = level, LogSource = "script", StepEventId = stepEventId }.ToJson()); /// /// The flow (inbound → route → site run) whose site run carries the asked-for /// log lines, optionally with lines belonging to an execution with no steps /// (the builder drops and counts those) and the inline log-truncation flag set. /// private static FlowDebugOutcome LoggedOutcome(int errors = 0, int warnings = 0, int infos = 0, bool logsTruncated = false, int unattached = 0) { 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: 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), }; var ms = 3; for (var i = 0; i < errors; i++) events.Add(LogLine(child, "error", ms++)); for (var i = 0; i < warnings; i++) events.Add(LogLine(child, "warn", ms++)); for (var i = 0; i < infos; i++) events.Add(LogLine(child, "info", ms++)); for (var i = 0; i < unattached; i++) events.Add(LogLine(Guid.NewGuid(), "info", ms++)); var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1), logsTruncated); return new FlowDebugOutcome(FlowGraphBuilder.Build(events, false, logsTruncated), raw, null, null, logsTruncated); } /// Picks the method, runs it against and waits for the status strip. private IRenderedComponent RunWith(FlowDebugOutcome outcome) { _flow.RunMethodAsync(4, Arg.Any(), Arg.Any()).Returns(outcome); var cut = Render(); cut.Find("select[data-test='flow-method-select']").Change("4"); FillRequiredMethodInput(cut); cut.Find("button[data-test='flow-run']").Click(); cut.WaitForElement("[data-test='flow-step-count']"); return cut; } [Fact] public void StatusStrip_ShowsLogCountsByLevel() { RegisterServices(); var cut = RunWith(LoggedOutcome(errors: 1, warnings: 2, infos: 3)); Assert.Equal("1 errors · 2 warnings · 3 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); } [Fact] public void StatusStrip_OmitsLogCounts_WhenTheRunLoggedNothing() { RegisterServices(); var cut = RunWith(LoggedOutcome()); Assert.Empty(cut.FindAll("[data-test='flow-log-counts']")); Assert.Empty(cut.FindAll("[data-test='flow-logs-truncated']")); Assert.Empty(cut.FindAll("[data-test='flow-unattached-logs']")); } [Fact] public void StatusStrip_ShowsTheInlineLogTruncationBadge() { // D7: the inline copy dropped low-priority rows; the audit store — and so Replay — is complete. RegisterServices(); var cut = RunWith(LoggedOutcome(infos: 1, logsTruncated: true)); Assert.Contains("inline log lines capped", cut.Find("[data-test='flow-logs-truncated']").TextContent); } [Fact] public void StatusStrip_NotesLinesThatCouldNotBePlacedOnAStep() { RegisterServices(); var cut = RunWith(LoggedOutcome(infos: 1, unattached: 2)); Assert.Contains("2 log line(s) could not be placed", cut.Find("[data-test='flow-unattached-logs']").TextContent); // The dropped lines are not counted on the strip — it counts only what landed on a step. Assert.Equal("0 errors · 0 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); } [Fact] public void StateChip_StaysCompleted_WhenTheOnlyErrorsAreLogLines() { // A Log.Error line is not a failed step: the run state is decided by step outcomes alone. RegisterServices(); var cut = RunWith(LoggedOutcome(errors: 2)); Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()); Assert.Equal("2 errors · 0 warnings · 0 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); } [Fact] public void StepDetail_LogHint_SelectsTheSiblingStepCarryingTheOtherLines() { RegisterServices(); var root = Guid.NewGuid(); var child = Guid.NewGuid(); var writeId = 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: 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.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered, eventId: writeId, occurredAtUtc: t0.AddMilliseconds(3), target: "Sim01.Flag", executionId: child, sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 2, extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("Sim01.Flag", "true", true, null)] }.ToJson()), LogLine(child, "info", 4), // → the ScriptRun anchor LogLine(child, "warn", 5, stepEventId: writeId), // → the write step }; var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1)); var cut = RunWith(new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null)); cut.Find("g[data-test='flow-node'][data-kind='ScriptRun']").Click(); cut.Find("[data-test='flow-step-tab-log']").Click(); var before = cut.Find("[data-test='flow-step-title']").TextContent.Trim(); var hint = cut.Find("[data-test='flow-step-log-hint'] button"); var otherTitle = hint.TextContent.Trim(); hint.Click(); // The page owns the selection: clicking the hint moves the detail panel AND the graph's // highlight to the sibling step. Assert.NotEqual(before, otherTitle); Assert.Equal(otherTitle, cut.Find("[data-test='flow-step-title']").TextContent.Trim()); var selected = cut.Find("[data-test='flow-node'].selected"); Assert.Equal("e:" + writeId.ToString("N"), selected.GetAttribute("data-key")); } // ---- D7: the inline cap flag is only true while the page is still short of lines -------------- /// /// A Live run that reported inline log truncation, plus the Replay a refresh reads back — sharing the /// run's own event objects (stable ids, as real audit rows are) and, when asked, one more log row than /// the run held. /// private static (FlowDebugOutcome Run, FlowDebugOutcome Refreshed, Guid Root) TruncatedThenRefreshed(bool refreshAddsALine) { var root = Guid.NewGuid(); var child = Guid.NewGuid(); var t0 = DateTime.UtcNow.AddSeconds(-5); 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: 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), LogLine(child, "info", 3), }; var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1), true); var run = new FlowDebugOutcome(FlowGraphBuilder.Build(events, false, true), raw, null, null, true); // The audit store is never truncated, so the re-read's own flag is false whatever it holds. var reread = new List(events); if (refreshAddsALine) reread.Add(LogLine(child, "warn", 4)); var rereadRaw = new FlowDebugRunResult(root, true, null, null, reread, false, t0, t0.AddSeconds(1)); var refreshed = new FlowDebugOutcome(FlowGraphBuilder.Build(reread), rereadRaw, null, null); return (run, refreshed, root); } [Fact] public void RefreshOutcomes_ClearsTheInlineCapBadge_OnceItMergesInMoreLogRows() { RegisterServices(); var (run, refreshed, root) = TruncatedThenRefreshed(refreshAddsALine: true); _flow.ReplayAsync(root, Arg.Any()).Returns(refreshed); var cut = RunWith(run); cut.Instance.Delay = (_, _) => Task.CompletedTask; Assert.NotNull(cut.Find("[data-test='flow-logs-truncated']")); cut.Find("button[data-test='flow-refresh']").Click(); cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='flow-logs-truncated']"))); Assert.Equal("0 errors · 1 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); } [Fact] public void RefreshOutcomes_KeepsTheInlineCapBadge_WhenTheReReadBringsBackNoNewLines() { // The site's rows reach central by telemetry seconds later — a refresh that adds nothing // has not proven the page holds every line. RegisterServices(); var (run, refreshed, root) = TruncatedThenRefreshed(refreshAddsALine: false); _flow.ReplayAsync(root, Arg.Any()).Returns(refreshed); var cut = RunWith(run); cut.Instance.Delay = (_, _) => Task.CompletedTask; cut.Find("button[data-test='flow-refresh']").Click(); cut.WaitForAssertion(() => _flow.Received(1).ReplayAsync(root, Arg.Any())); Assert.NotNull(cut.Find("[data-test='flow-logs-truncated']")); Assert.Equal("0 errors · 0 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); } // ---- CountLogs: the level buckets, straight --------------------------------------------------- [Fact] public void CountLogs_BucketsByLevel_CaseInsensitively_WithEverythingElseAsInfo() { var execution = Guid.NewGuid(); var t0 = DateTime.UtcNow; var graph = FlowGraphBuilder.Build([ ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, occurredAtUtc: t0, target: "Process", executionId: execution, sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80), LogLine(execution, "ERROR", 1), LogLine(execution, "Warn", 2), LogLine(execution, "info", 3), LogLine(execution, "debug", 4), LogLine(execution, "trace", 5), // an unknown level is still a line — it lands in info ]); Assert.Equal((1, 1, 3, 5), FlowDebuggerPage.CountLogs(graph)); } [Fact] public void CountLogs_NullGraph_IsAllZeroes() { Assert.Equal((0, 0, 0, 0), FlowDebuggerPage.CountLogs(null)); } /// /// Waits for the method pane's ParameterValueForm and fills the fixture method's one /// required field, so Run is enabled (#37 gates Run on the form's validity). /// private static void FillRequiredMethodInput(IRenderedComponent cut) { cut.WaitForElement("#param-MachineCode").Input("Z28061"); cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"))); } // ── #37: structured method parameters ─────────────────────────────────────────────────── [Fact] public void MethodEntry_ArrayOfObjects_RoundTripsFromItemEditorsToTheRunPayload() { RegisterServices(); JsonElement? sent = null; _flow.RunMethodAsync(5, 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("5"); cut.WaitForElement("#param-SAPID"); // Required array unset → Run off. Add two work orders through the list editor. Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); cut.Find("#param-SAPID").Input("103559_A"); cut.Find("button.param-list-add").Click(); cut.Find("button.param-list-add").Click(); cut.Find("[id='param-WorkOrders[0].WorkOrderNumber']").Input("WO-1"); cut.Find("[id='param-WorkOrders[0].PartNumber']").Input("P-1"); cut.Find("[id='param-WorkOrders[1].WorkOrderNumber']").Input("WO-2"); cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"))); 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("103559_A", sent!.Value.GetProperty("SAPID").GetString()); var orders = sent.Value.GetProperty("WorkOrders"); Assert.Equal(JsonValueKind.Array, orders.ValueKind); Assert.Equal(2, orders.GetArrayLength()); Assert.Equal("WO-1", orders[0].GetProperty("WorkOrderNumber").GetString()); Assert.Equal("P-1", orders[0].GetProperty("PartNumber").GetString()); Assert.Equal("WO-2", orders[1].GetProperty("WorkOrderNumber").GetString()); } [Fact] public void MethodEntry_RequiredListItemField_LeftEmpty_KeepsRunDisabled() { RegisterServices(); var cut = Render(); cut.Find("select[data-test='flow-method-select']").Change("5"); cut.WaitForElement("#param-SAPID"); cut.Find("#param-SAPID").Input("103559_A"); cut.Find("button.param-list-add").Click(); // An item exists but its required WorkOrderNumber is unset: still invalid. cut.Find("[id='param-WorkOrders[0].PartNumber']").Input("P-1"); Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); cut.Find("[id='param-WorkOrders[0].WorkOrderNumber']").Input("WO-1"); cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"))); } [Fact] public async Task MethodEntry_RawJson_InvalidDocument_BlocksRun_AndShowsTheError() { RegisterServices(); var cut = Render(); cut.Find("select[data-test='flow-method-select']").Change("4"); FillRequiredMethodInput(cut); cut.Find("button[data-test='flow-input-mode-raw']").Click(); var editor = cut.FindComponent(); Assert.Equal("json", editor.Instance.Language); // Entering raw mode seeds the editor with what the form would have sent. Assert.Contains("Z28061", editor.Instance.Value); Assert.Empty(cut.FindAll("[data-test='flow-method-params']")); // Malformed JSON: no run, inline error. await cut.InvokeAsync(() => editor.Instance.ValueChanged.InvokeAsync("{\"MachineCode\": ")); cut.WaitForAssertion(() => Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"))); Assert.Contains("JSON parse error", cut.Find("[data-test='flow-method-raw-error']").TextContent); // Well-formed but schema-invalid (Qty is an Integer): still no run, schema error shown. await cut.InvokeAsync(() => editor.Instance.ValueChanged.InvokeAsync("{\"MachineCode\":\"Z1\",\"Qty\":\"three\"}")); cut.WaitForAssertion(() => Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"))); Assert.Contains("Qty", cut.Find("[data-test='flow-method-raw-error']").TextContent); // A non-object document is refused too. await cut.InvokeAsync(() => editor.Instance.ValueChanged.InvokeAsync("[1,2]")); cut.WaitForAssertion(() => Assert.Contains("must be a JSON object", cut.Find("[data-test='flow-method-raw-error']").TextContent)); _ = _flow.DidNotReceive().RunMethodAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task MethodEntry_RawJson_ValidDocument_IsSentVerbatim_AndSurvivesTheSwitchBackToForm() { RegisterServices(); JsonElement? sent = null; _flow.RunMethodAsync(5, 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("5"); cut.WaitForElement("#param-SAPID"); cut.Find("button[data-test='flow-input-mode-raw']").Click(); var editor = cut.FindComponent(); const string body = "{\"SAPID\":\"103559_A\",\"WorkOrders\":[{\"WorkOrderNumber\":\"WO-1\",\"PartNumber\":\"P-1\"}]}"; await cut.InvokeAsync(() => editor.Instance.ValueChanged.InvokeAsync(body)); cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"))); Assert.Empty(cut.FindAll("[data-test='flow-method-raw-error']")); // Back to the form: the parsed tree is what the item editors now show. cut.Find("button[data-test='flow-input-mode-form']").Click(); cut.WaitForElement("[id='param-WorkOrders[0].WorkOrderNumber']"); Assert.Equal("WO-1", cut.Find("[id='param-WorkOrders[0].WorkOrderNumber']").GetAttribute("value")); Assert.Equal("103559_A", cut.Find("#param-SAPID").GetAttribute("value")); cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"))); 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("WO-1", sent!.Value.GetProperty("WorkOrders")[0].GetProperty("WorkOrderNumber").GetString()); Assert.Equal("P-1", sent.Value.GetProperty("WorkOrders")[0].GetProperty("PartNumber").GetString()); } [Fact] public void MethodEntry_SwitchingMethods_ResetsTheFormAndItsValidity() { RegisterServices(); var cut = Render(); cut.Find("select[data-test='flow-method-select']").Change("4"); FillRequiredMethodInput(cut); cut.Find("select[data-test='flow-method-select']").Change("5"); cut.WaitForElement("#param-SAPID"); Assert.Empty(cut.FindAll("#param-MachineCode")); Assert.Equal("", cut.Find("#param-SAPID").GetAttribute("value")); Assert.Empty(cut.FindAll(".param-list-row")); Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); } [Fact] public void MethodEntry_PersistedValueTree_IsRestoredIntoTheForm() { RegisterServices(); JSInterop.Setup("localStorage.getItem", "flowDebugger.inputs.4") .SetResult("{\"MachineCode\":\"Z9\",\"Qty\":7}"); var cut = Render(); cut.Find("select[data-test='flow-method-select']").Change("4"); cut.WaitForAssertion(() => Assert.Equal("Z9", cut.Find("#param-MachineCode").GetAttribute("value"))); Assert.Equal("7", cut.Find("#param-Qty").GetAttribute("value")); cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"))); } [Fact] public void MethodEntry_LegacyFlatStringEntry_ThatNoLongerValidates_IsDropped() { RegisterServices(); // Pre-#37 entries stored every field as a string; "7" is not an Integer, so the whole // entry is discarded rather than painted into the form as an error. JSInterop.Setup("localStorage.getItem", "flowDebugger.inputs.4") .SetResult("{\"MachineCode\":\"Z9\",\"Qty\":\"7\"}"); var cut = Render(); cut.Find("select[data-test='flow-method-select']").Change("4"); cut.WaitForElement("#param-MachineCode"); Assert.Equal("", cut.Find("#param-MachineCode").GetAttribute("value")); Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); } }