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.Commons.Entities.InboundApi; 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 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") { 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); var identity = new ClaimsIdentity( [new Claim(ClaimTypes.Name, "deployer"), new Claim(ClaimTypes.Role, role)], "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")); } [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']"); } }