52726cf30d
Three final-review findings on the two picker pages: 1. DebugView's localStorage auto-reconnect restored the stored instance id with no membership check, where the ?instanceId= path has one — a stored instance that is no longer Enabled (or was deleted, or moved sites) is filtered out of the list the picker is handed, so the field rendered blank while the page held a non-zero id and auto-connected to it anyway. The guard now mirrors the deep-link path: forget the stored instance id, tell the operator, and leave the site selected and loaded so they can pick again. 2. FlowDebugger's _instances kept an OrderBy(UniqueName) from the flat <select> era; InstanceTreeBuilder sorts areas and instances itself. 3. The deep-link test is joined by one where the instance sits two areas deep, so the field text is pinned to resolve from the flat list with the panel never opened. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
907 lines
49 KiB
C#
907 lines
49 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.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;
|
|
|
|
/// <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 readonly IDialogService _dialog = Substitute.For<IDialogService>();
|
|
private readonly ISchemaLibraryQueryService _schemaLibrary = Substitute.For<ISchemaLibraryQueryService>();
|
|
|
|
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", IEnumerable<Claim>? extraClaims = null)
|
|
{
|
|
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);
|
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DialogSeverity>(), Arg.Any<string?>()).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<CancellationToken>()).Returns(new Dictionary<string, string>());
|
|
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<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"));
|
|
// 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<FlowDebuggerPage>();
|
|
|
|
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<CancellationToken>()).Returns(parked);
|
|
var nav = Services.GetRequiredService<NavigationManager>();
|
|
nav.NavigateTo($"/deployment/flow-debugger?executionId={root}");
|
|
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<CancellationToken>());
|
|
}
|
|
|
|
[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<NavigationManager>();
|
|
nav.NavigateTo("/deployment/flow-debugger?methodId=4");
|
|
|
|
var cut = Render<FlowDebuggerPage>();
|
|
|
|
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<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']");
|
|
}
|
|
|
|
// ---- Node actions (follow-ups plan 2026-09-06, Task 8): Retry / Discard on a Parked node -------
|
|
|
|
/// <summary>Same events as <see cref="OutboundOutcome"/> 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.</summary>
|
|
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<AuditEvent>
|
|
{
|
|
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<AuditEvent>(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<FlowDebuggerPage> RunToParkedNode(Guid root, FlowDebugOutcome parked)
|
|
{
|
|
_flow.RunMethodAsync(4, Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(parked);
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<FlowStep>(), Arg.Any<CancellationToken>())
|
|
.Returns(new FlowOutboundActionResult(true, "Retry of abc relayed to site-a."));
|
|
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(parked, resolved);
|
|
var cut = RunToParkedNode(root, parked);
|
|
_flow.DidNotReceive().ReplayAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()); // Parked is not Pending: no auto-poll
|
|
|
|
cut.Find("button[data-test='flow-step-retry']").Click();
|
|
|
|
cut.WaitForAssertion(() => _flow.Received(1).RetryOutboundAsync(Arg.Is<FlowStep>(s => s.EventId == stepId), Arg.Any<CancellationToken>()));
|
|
_dialog.Received(1).ConfirmAsync("Retry cached call", Arg.Is<string>(m => m.Contains("Delmia.Ack")), Arg.Any<bool>(), Arg.Any<string?>());
|
|
cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any<CancellationToken>()));
|
|
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<FlowStep>(), Arg.Any<CancellationToken>())
|
|
.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<FlowStep>(), Arg.Any<CancellationToken>()));
|
|
_dialog.Received(1).ConfirmAsync("Discard cached call", Arg.Is<string>(m => m.Contains("cannot be undone")), true, "Discard");
|
|
cut.WaitForAssertion(() => Assert.Contains("Site unreachable", cut.Markup));
|
|
_flow.DidNotReceive().ReplayAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
|
Assert.NotNull(cut.Find("button[data-test='flow-step-retry']"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ParkedNode_ConfirmDeclined_NeverRelays()
|
|
{
|
|
RegisterServices();
|
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).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<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()));
|
|
_flow.DidNotReceive().RetryOutboundAsync(Arg.Any<FlowStep>(), Arg.Any<CancellationToken>());
|
|
_flow.DidNotReceive().ReplayAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[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<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>());
|
|
_flow.DidNotReceive().RetryOutboundAsync(Arg.Any<FlowStep>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ---- Outbound calls (2026-09-06 plan, Task 9): pending refresh after a Live run ----------
|
|
|
|
/// <summary>
|
|
/// Inbound → route → site run → cached API call; the call is Pending unless <paramref name="resolved"/>.
|
|
/// Both outcomes of one <paramref name="root"/> share their rows (same event/correlation ids), as the
|
|
/// real system does: the inline Live rows ARE the audit rows Replay later reads back.
|
|
/// </summary>
|
|
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<AuditEvent>(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<Guid, (List<AuditEvent> Events, Guid Child, Guid Corr, DateTime T0)> OutboundBase = new();
|
|
|
|
private static List<AuditEvent> BuildOutboundBase(Guid root, Guid child, Guid corr, DateTime t0)
|
|
{
|
|
return new List<AuditEvent>
|
|
{
|
|
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<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(OutboundOutcome(root, resolved: false));
|
|
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(OutboundOutcome(root, resolved: false), OutboundOutcome(root, resolved: true));
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<CancellationToken>()));
|
|
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']"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Live run whose inline site rows already resolved a cached DB write, with a notification still
|
|
/// Pending; <paramref name="withDbRows"/>=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).
|
|
/// </summary>
|
|
private static FlowDebugOutcome DbAndNotifyOutcome(Guid root, Guid child, Guid dbCorr, Guid notifyCorr, DateTime t0, bool withDbRows, bool notifyDelivered)
|
|
{
|
|
var events = new List<AuditEvent>
|
|
{
|
|
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<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(live);
|
|
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(lagging);
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<CancellationToken>()));
|
|
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<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(OutboundOutcome(root, resolved: true));
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<Guid>(), Arg.Any<CancellationToken>());
|
|
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<CancellationToken>()).Returns(OutboundOutcome(root, resolved: false));
|
|
var nav = Services.GetRequiredService<NavigationManager>();
|
|
nav.NavigateTo($"/deployment/flow-debugger?executionId={root}");
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<CancellationToken>());
|
|
|
|
cut.Find("button[data-test='flow-refresh']").Click();
|
|
|
|
cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any<CancellationToken>()));
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Picks an instance through the <c>InstancePicker</c> combobox that replaced the flat
|
|
/// instance <c><select></c> (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.
|
|
/// </summary>
|
|
private static void PickInstance(IRenderedComponent<FlowDebuggerPage> cut, int id)
|
|
{
|
|
cut.Find("[data-test='flow-instance-toggle']").Click();
|
|
cut.Find($"[data-test='flow-instance-row'][data-instance-id='{id}']").Click();
|
|
}
|
|
|
|
/// <summary>Clicks the picker row (an area label toggles it, an instance label selects it).</summary>
|
|
private static void ClickPickerRow(IRenderedComponent<FlowDebuggerPage> 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<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Instance>>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
|
|
_templates.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Area>>([]));
|
|
_templates.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 });
|
|
_flow.GetInstanceScriptsAsync(7, Arg.Any<CancellationToken>()).Returns(Task.FromResult<IReadOnlyList<FlowScriptOption>>([Manual, OnFlag, Tick]));
|
|
_flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptEntry_ListsScriptsWithTriggerLabels_AfterSiteAndInstance()
|
|
{
|
|
RegisterServices();
|
|
WireInstance();
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<FlowDebuggerPage>();
|
|
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<JsonElement?>(), null, Arg.Any<CancellationToken>()).Returns(GraphOutcome(true, true));
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<JsonElement?>(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptEntry_CallKindWithoutParameters_SaysSo_AndCanRun()
|
|
{
|
|
RegisterServices();
|
|
WireInstance();
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<NavigationManager>();
|
|
nav.NavigateTo("/deployment/flow-debugger?instanceId=7&script=Manual");
|
|
|
|
var cut = Render<FlowDebuggerPage>();
|
|
|
|
cut.WaitForAssertion(() => Assert.Equal("Manual", cut.Find("select[data-test='flow-script-select']").GetAttribute("value")));
|
|
_ = _flow.Received().PredictScriptAsync(7, "Manual", Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Two call-kind scripts declaring BYTE-IDENTICAL parameter definitions — the case that makes
|
|
/// <c>ParameterValueForm</c> skip its reparse (and its <c>ValidChanged</c>) and keeps the previous
|
|
/// script's raw text and parse errors. The page must remount the form per script instead.
|
|
/// </summary>
|
|
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<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Instance>>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
|
|
_flow.GetInstanceScriptsAsync(7, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<FlowScriptOption>>([Manual, Manual2, Tick]));
|
|
_flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
|
|
}
|
|
|
|
private IRenderedComponent<FlowDebuggerPage> ChooseScriptWithAnInvalidParameter()
|
|
{
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Area>>(
|
|
[new Area("Line1") { Id = 1, SiteId = 1 }, new Area("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }]));
|
|
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Instance>>(
|
|
[new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10, AreaId = 2 }]));
|
|
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<NavigationManager>();
|
|
nav.NavigateTo("/deployment/flow-debugger?instanceId=7");
|
|
|
|
var cut = Render<FlowDebuggerPage>();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value")));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The same deep link, but the instance sits two areas deep. The picker resolves the field
|
|
/// text from <c>SelectedInstanceId</c> 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).
|
|
/// </summary>
|
|
[Fact]
|
|
public void ScriptEntry_DeepLink_InstanceIdInsideAnArea_ShowsItsNameWithoutOpeningThePanel()
|
|
{
|
|
RegisterServices();
|
|
WireInstance();
|
|
_templates.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Area>>(
|
|
[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<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Instance>>([nested]));
|
|
_templates.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>()).Returns(nested);
|
|
|
|
var nav = Services.GetRequiredService<NavigationManager>();
|
|
nav.NavigateTo("/deployment/flow-debugger?instanceId=7");
|
|
|
|
var cut = Render<FlowDebuggerPage>();
|
|
|
|
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']"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The picker must stay disabled for the WHOLE site load, not just until the site id lands.
|
|
/// Blazor renders as soon as <c>OnSiteChangedAsync</c> hits its first await, and at that point
|
|
/// <c>_selectedSiteId</c> is already non-zero while <c>_instances</c>/<c>_areas</c> are still the
|
|
/// previous site's lists — so an operator (or Playwright's <c>FillAsync</c>, 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".
|
|
/// </summary>
|
|
[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<IReadOnlyList<Instance>>();
|
|
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>()).Returns(gate.Task);
|
|
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<CancellationToken>()).Returns(Task.FromResult<IReadOnlyList<Site>>(
|
|
[new Site("Plant A", "site-a") { Id = 1 }, new Site("Plant B", "site-b") { Id = 2 }]));
|
|
_templates.GetInstancesBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Instance>>([]));
|
|
_templates.GetAreasBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Area>>([]));
|
|
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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());
|
|
}
|
|
}
|