254118a467
/deployment/flow-debugger (RequireDeployment): entry toggle (inbound method | value-change trigger), method picker or Site → Instance → Attribute pickers (site-scope filtered), Live | Replay mode, inputs form generated from the method's ParameterDefinitions via InboundApiSchema (values coerced by declared type into the request JsonElement), status strip (state chip, run #, start, total, steps/boundaries/writes, root ExecutionId link to the Execution Chain page), the FlowGraphView with −/Fit/+ zoom, and FlowStepDetail (tabs Input · Output · Writes · Timing, 'Where this ran' column, Open in Debug View when the instance is known, Audit rows (n) → /audit/log?executionId=). State machine Idle → Predicted → Running → Completed | Failed | TimedOut; STANDBY_NODE renders as an inline warning alert with the server message verbatim; ?methodId= / ?instanceId=&attribute= / ?executionId= drill-ins (the last replays on first render); last entry + inputs persisted per method in localStorage; 'site not traced' label when a run's route call came back with no child execution and no Site-lane steps. NavMenu: Deployment → Flow Debugger. Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
213 lines
10 KiB
C#
213 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>?executionId=</c>, and the "site not traced" label.
|
|
/// </summary>
|
|
public class FlowDebuggerPageTests : BunitContext
|
|
{
|
|
private readonly IFlowDebugService _flow = Substitute.For<IFlowDebugService>();
|
|
private readonly IInboundApiRepository _inboundApi = Substitute.For<IInboundApiRepository>();
|
|
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
|
|
private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>();
|
|
|
|
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<AuditEvent>
|
|
{
|
|
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<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>([Delmia]));
|
|
_inboundApi.GetApiMethodByIdAsync(4, Arg.Any<CancellationToken>()).Returns(Delmia);
|
|
_sites.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Site>>([new Site("Plant A", "site-a") { Id = 1 }]));
|
|
_flow.PredictMethodAsync(4, Arg.Any<CancellationToken>()).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<AuthenticationStateProvider>(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<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state);
|
|
}
|
|
|
|
[Fact]
|
|
public void Renders_ForDeployer_WithToolbarAndEmptyFlow()
|
|
{
|
|
RegisterServices();
|
|
|
|
var cut = Render<FlowDebuggerPage>();
|
|
|
|
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<FlowDebuggerPage>();
|
|
|
|
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<JsonElement?>(p => sent = p), Arg.Any<CancellationToken>())
|
|
.Returns(GraphOutcome(withSite: true, withChildId: true));
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<JsonElement?>(), Arg.Any<CancellationToken>())
|
|
.Returns(FlowDebugOutcome.Fail("This node is standby; run the flow on the active central node.", "STANDBY_NODE"));
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<CancellationToken>()).Returns(GraphOutcome(withSite: true, withChildId: true));
|
|
var nav = Services.GetRequiredService<NavigationManager>();
|
|
nav.NavigateTo($"/deployment/flow-debugger?executionId={exec}");
|
|
|
|
var cut = Render<FlowDebuggerPage>();
|
|
|
|
cut.WaitForAssertion(() => _flow.Received(1).ReplayAsync(exec, Arg.Any<CancellationToken>()));
|
|
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<JsonElement?>(), Arg.Any<CancellationToken>())
|
|
.Returns(GraphOutcome(withSite: false, withChildId: false));
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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']");
|
|
}
|
|
}
|