b5eb08f1a9
The Run-inbound-API-method pane rendered one text box per top-level schema field and coerced the text on the page; an array/object field went through JsonNode.Parse and, on failure, was SENT AS A STRING, while an empty box was silently dropped. A required array-of-object parameter (MesMoveOut WorkOrders) could not be entered at all — QA runs reached the site with "WorkOrders":[]. - The method pane now renders the same shared ParameterValueForm the script pane uses (typed inputs, nested groups, add/remove list item editors, $ref:lib: resolution, inline validation), keyed on the method id so a change of method remounts it. The page keeps ONE value tree and BuildParameters() serializes it as-is; the flat _inputs / Coerce() / SetInput path is deleted. - Form | Raw JSON toggle: raw mode is a Monaco json editor for the whole request body, given the method's root schema for completion, parsed and schema-validated (InboundApiSchema.Validate) on every edit with the error inline. Entering raw mode seeds the editor from the tree; leaving it re-mounts the form over the last valid tree. - Run is disabled while the form or the raw document is invalid (a required field left empty disables Run before the first keystroke). - localStorage per-method entry stores the value tree; on restore it is validated against the schema and dropped if invalid, which also retires the pre-#37 flat string-per-field entries. - ParameterValueForm.ToJsonSchema / JsonElementToObject made internal static for page reuse. - Tests: 7 new bUnit tests (WorkOrders array-of-object round-trip to the RunMethodAsync payload, required list-item gating, invalid raw JSON blocks Run + shows parse/schema/non-object errors, valid raw JSON sent verbatim and survives the switch back to the form, method switch resets, persisted tree restored, legacy flat entry dropped); existing method-pane tests moved to the form's #param-<field> ids and fill the required field before Run. Playwright smoke selector moved to #param-Text. - Docs: design doc, Component-CentralUI.md entry-points bullet, CLAUDE.md. CentralUI.Tests 1425/1425 green; Playwright project builds. Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
1372 lines
74 KiB
C#
1372 lines
74 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"]}""",
|
|
};
|
|
|
|
/// <summary>
|
|
/// #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).
|
|
/// </summary>
|
|
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<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, MesMoveOut]));
|
|
_inboundApi.GetApiMethodByIdAsync(4, Arg.Any<CancellationToken>()).Returns(Delmia);
|
|
_inboundApi.GetApiMethodByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MesMoveOut);
|
|
_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());
|
|
_flow.PredictMethodAsync(5, 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();
|
|
Services.AddTestConfigurationMutations();
|
|
}
|
|
|
|
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']"));
|
|
// #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<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");
|
|
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<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");
|
|
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<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");
|
|
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 -------
|
|
|
|
/// <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");
|
|
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<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");
|
|
FillRequiredMethodInput(cut);
|
|
|
|
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");
|
|
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<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");
|
|
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<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());
|
|
}
|
|
|
|
// ---- Step logs (plan 2026-09-09 D5/D7, Task 13): status-strip counts, truncation notes, cross-step hint ----
|
|
|
|
/// <summary>One <c>ScriptTrace.ScriptLog</c> row — a line the builder folds onto a step, never a node of its own.</summary>
|
|
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());
|
|
|
|
/// <summary>
|
|
/// The <see cref="GraphOutcome"/> flow (inbound → route → site run) whose site run carries the asked-for
|
|
/// log lines, optionally with <paramref name="unattached"/> lines belonging to an execution with no steps
|
|
/// (the builder drops and counts those) and the inline log-truncation flag set.
|
|
/// </summary>
|
|
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<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: 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);
|
|
}
|
|
|
|
/// <summary>Picks the method, runs it against <paramref name="outcome"/> and waits for the status strip.</summary>
|
|
private IRenderedComponent<FlowDebuggerPage> RunWith(FlowDebugOutcome outcome)
|
|
{
|
|
_flow.RunMethodAsync(4, Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(outcome);
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<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: 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 --------------
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<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: 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<AuditEvent>(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<CancellationToken>()).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<CancellationToken>()).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<CancellationToken>()));
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Waits for the method pane's <c>ParameterValueForm</c> and fills the fixture method's one
|
|
/// required field, so Run is enabled (#37 gates Run on the form's validity).
|
|
/// </summary>
|
|
private static void FillRequiredMethodInput(IRenderedComponent<FlowDebuggerPage> 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<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("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<FlowDebuggerPage>();
|
|
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<FlowDebuggerPage>();
|
|
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<MonacoEditor>();
|
|
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<int>(), Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MethodEntry_RawJson_ValidDocument_IsSentVerbatim_AndSurvivesTheSwitchBackToForm()
|
|
{
|
|
RegisterServices();
|
|
JsonElement? sent = null;
|
|
_flow.RunMethodAsync(5, 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("5");
|
|
cut.WaitForElement("#param-SAPID");
|
|
|
|
cut.Find("button[data-test='flow-input-mode-raw']").Click();
|
|
var editor = cut.FindComponent<MonacoEditor>();
|
|
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<FlowDebuggerPage>();
|
|
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<string?>("localStorage.getItem", "flowDebugger.inputs.4")
|
|
.SetResult("{\"MachineCode\":\"Z9\",\"Qty\":7}");
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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<string?>("localStorage.getItem", "flowDebugger.inputs.4")
|
|
.SetResult("{\"MachineCode\":\"Z9\",\"Qty\":\"7\"}");
|
|
var cut = Render<FlowDebuggerPage>();
|
|
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"));
|
|
}
|
|
}
|