Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs
T
Joseph Doherty 7caf56f332 fix(central-ui): Flow Debugger outcome refresh merges rows by EventId instead of replacing the graph
Found in a browser Live run on the rig: the run drew API, DB and Notify nodes from
its inline rows; the 5 s pending-refresh re-read the tree through Replay before the
site's cached-call telemetry (12 s) had reached central, swapped the graph wholesale,
and the resolved DB node vanished (7 -> 6 steps) until a manual refresh. Polling had
already stopped because nothing was Pending.

MergeRefreshedGraph now unions the re-read rows into the run's known rows by EventId
(fresh copy wins), stores the union back on _raw and rebuilds through
FlowGraphBuilder, so a refresh only ever adds nodes. The union is exact: the inline
rows are the same AuditEvent objects the site writer persists (FlowTraceCollector).

Tests: new PendingRefresh_KeepsStepsTheLiveRunAlreadyHad_WhenReplayLagsBehindTelemetry
(fails on the old code). Two existing fixtures were internally inconsistent and only
passed under the old replace semantics: ParkedThenResolved built its Resolved outcome
with the Parked raw rows, and OutboundOutcome minted fresh event/correlation ids per
call, so no row could ever match across live and replay. Both now share rows per run.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 11:55:52 -04:00

526 lines
30 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.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 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);
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"));
}
[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());
}
}