Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs
T

366 lines
17 KiB
C#

using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Flow Debugger outbound-calls plan (2026-09-06) Task 8: the step detail panel's Lifecycle tab,
/// outbound "Where this ran" fields and kind-specific Input tab labels. Follow-ups plan (2026-09-06)
/// Task 6: report deep links from a correlated outbound step and Retry/Discard on a parked one (D2/D5).
/// Step-logs plan (2026-09-09) Task 12: the Log tab — exception block, level-filtered lines and the
/// cross-step hint (design D8).
/// </summary>
public class FlowStepDetailTests : BunitContext
{
private static readonly DateTimeOffset T0 = new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero);
public FlowStepDetailTests()
{
// The Log tab's Copy buttons call navigator.clipboard through IJSRuntime.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private const string ExceptionDetail =
"System.InvalidOperationException: Recipe 42 rejected by Galaxy (code 7)\n" +
" at Submission#0.<<Initialize>>d__0.MoveNext() in ProcessRecipeDownload:line 41\n" +
" at ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptRunLauncher.RunScriptAsync()";
/// <summary>A site script-run step, optionally carrying folded log lines and a thrown exception's detail.</summary>
private static FlowStep ScriptStep(
FlowStepOutcome outcome,
string title,
Guid executionId,
IReadOnlyList<FlowLogLine>? logs = null,
string? errorDetail = null)
=> new(Guid.NewGuid(), executionId, null, FlowStepKind.ScriptRun, FlowLane.Site, title,
"DelmiaSim01.DelmiaReceiver \u00b7 call", T0, 1731, outcome, "{\"RecipeId\":42}", "{\"ok\":false}",
outcome == FlowStepOutcome.Failed ? "Recipe 42 rejected by Galaxy (code 7)" : null,
"site-a", "node-a", "DelmiaSim01", title, null, null, null, errorDetail, logs);
private static FlowLogLine Line(
string level,
string message,
string source = "script",
int seq = 0,
string? exceptionMessage = null,
string? exceptionDetail = null)
=> new(Guid.NewGuid(), null, T0.AddMilliseconds(seq), level, source, message,
exceptionMessage, exceptionDetail, null, seq);
private static FlowStep Step(FlowStepKind kind, FlowStepOutcome outcome, string target, IReadOnlyList<FlowLifecycleRow>? lifecycle = null, Guid? correlationId = null)
=> new(Guid.NewGuid(), Guid.NewGuid(), null, kind, FlowLane.External, target, "Delivered · cached", T0, 4100, outcome,
"{\"id\":1}", "{\"ok\":true}", outcome == FlowStepOutcome.Failed ? "boom" : null, "site-a", "node-a", "M1", target, null, lifecycle, correlationId);
private static IReadOnlyList<FlowLifecycleRow> CachedLifecycle(string terminalStatus) =>
[
new(Guid.NewGuid(), "CachedSubmit", "Submitted", T0, "node-a", null, null),
new(Guid.NewGuid(), "ApiCallCached", "Attempted", T0.AddSeconds(3), "node-a", "503 Service Unavailable", 40),
new(Guid.NewGuid(), "CachedResolve", terminalStatus, T0.AddMilliseconds(4100), "node-b", null, null),
];
[Fact]
public void LifecycleTab_RendersRowsInTimeOrder_AndDeliveredBy()
{
var step = Step(FlowStepKind.ApiCall, FlowStepOutcome.Ok, "Delmia.AckRecipe", CachedLifecycle("Delivered"));
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
cut.Find("[data-test=flow-step-tab-lifecycle]").Click();
var rows = cut.FindAll("[data-test=flow-step-lifecycle] tbody tr");
Assert.Equal(3, rows.Count);
Assert.Contains("CachedSubmit", rows[0].TextContent);
Assert.Contains("Submitted", rows[0].TextContent);
Assert.Contains("503 Service Unavailable", rows[1].TextContent);
Assert.Contains("CachedResolve", rows[2].TextContent);
Assert.Contains("node-b", rows[2].TextContent);
Assert.Contains("12:00:04.100", rows[2].TextContent);
var where = cut.Find("[data-test=flow-step-where]").TextContent;
Assert.Contains("System", where);
Assert.Contains("Delmia.AckRecipe", where);
Assert.Contains("Delivered by", where);
Assert.Contains("node-b", where);
Assert.Empty(cut.FindAll("[data-test=flow-step-parked-hint]"));
}
[Fact]
public void NoLifecycle_HidesTheTab()
{
var step = Step(FlowStepKind.AttributeWrite, FlowStepOutcome.Ok, "A") with { Lane = FlowLane.Tags };
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
Assert.Empty(cut.FindAll("[data-test=flow-step-tab-lifecycle]"));
Assert.Equal("Input", cut.Find("[data-test=flow-step-tab-input]").TextContent.Trim());
}
[Fact]
public void ParkedNotify_ShowsHint_AndListLabel_AndParkedBadge()
{
var lifecycle = new List<FlowLifecycleRow>
{
new(Guid.NewGuid(), "NotifySend", "Submitted", T0, "node-a", null, null),
new(Guid.NewGuid(), "NotifyDeliver", "Parked", T0.AddSeconds(9), "central-a", "retries exhausted", null),
};
var step = Step(FlowStepKind.Notify, FlowStepOutcome.Parked, "Planners", lifecycle);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
Assert.Equal("Parked", cut.Find("[data-test=flow-step-outcome]").TextContent.Trim());
Assert.Contains("warning", cut.Find("[data-test=flow-step-outcome]").ClassName);
Assert.Equal("Subject + body", cut.Find("[data-test=flow-step-tab-input]").TextContent.Trim());
var where = cut.Find("[data-test=flow-step-where]").TextContent;
Assert.Contains("List", where);
Assert.Contains("central-a", where);
cut.Find("[data-test=flow-step-tab-lifecycle]").Click();
Assert.Contains("Site Calls / Notification", cut.Find("[data-test=flow-step-parked-hint]").TextContent);
}
[Theory]
[InlineData(FlowStepKind.ApiCall, "Request", "System")]
[InlineData(FlowStepKind.DbCall, "SQL + parameters", "Connection")]
[InlineData(FlowStepKind.Notify, "Subject + body", "List")]
public void OutboundKinds_LabelInputTabAndTarget(FlowStepKind kind, string inputLabel, string targetLabel)
{
var step = Step(kind, FlowStepOutcome.Pending, "T");
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
Assert.Equal(inputLabel, cut.Find("[data-test=flow-step-tab-input]").TextContent.Trim());
Assert.Contains(targetLabel, cut.Find("[data-test=flow-step-where]").TextContent);
Assert.Equal("Pending", cut.Find("[data-test=flow-step-outcome]").TextContent.Trim());
}
[Fact]
public void OutboundStepWithCorrelation_LinksToTheReportPage()
{
var c = Guid.NewGuid();
var api = Step(FlowStepKind.ApiCall, FlowStepOutcome.Ok, "Delmia.AckRecipe", CachedLifecycle("Delivered"), c);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, api));
var link = cut.Find("a[data-test=open-report]");
Assert.Equal($"/site-calls/report?correlationId={c:D}", link.GetAttribute("href"));
Assert.Equal("Open in Site Calls", link.TextContent.Trim());
var notify = Step(FlowStepKind.Notify, FlowStepOutcome.Ok, "Planners", CachedLifecycle("Delivered"), c);
cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, notify));
link = cut.Find("a[data-test=open-report]");
Assert.Equal($"/notifications/report?correlationId={c:N}", link.GetAttribute("href"));
Assert.Equal("Open in Notification report", link.TextContent.Trim());
var sync = Step(FlowStepKind.ApiCall, FlowStepOutcome.Ok, "Delmia.AckRecipe");
cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, sync));
Assert.Empty(cut.FindAll("[data-test=open-report]"));
}
[Fact]
public void ParkedStep_WithCallbacks_ShowsRetryAndDiscard()
{
var c = Guid.NewGuid();
var step = Step(FlowStepKind.ApiCall, FlowStepOutcome.Parked, "Delmia.AckRecipe", CachedLifecycle("Parked"), c);
FlowStep? retried = null;
FlowStep? discarded = null;
var cut = Render<FlowStepDetail>(p => p
.Add(x => x.Step, step)
.Add(x => x.OnRetry, s => retried = s)
.Add(x => x.OnDiscard, s => discarded = s));
var retry = cut.Find("button[data-test=flow-step-retry]");
var discard = cut.Find("button[data-test=flow-step-discard]");
Assert.Equal("Retry", retry.TextContent.Trim());
Assert.Equal("Discard", discard.TextContent.Trim());
Assert.Contains("btn-outline-primary", retry.ClassName);
Assert.Contains("btn-outline-danger", discard.ClassName);
Assert.False(retry.HasAttribute("disabled"));
Assert.False(discard.HasAttribute("disabled"));
retry.Click();
Assert.Same(step, retried);
Assert.Null(discarded);
discard.Click();
Assert.Same(step, discarded);
cut.Render(p => p
.Add(x => x.Step, step)
.Add(x => x.OnRetry, s => retried = s)
.Add(x => x.OnDiscard, s => discarded = s)
.Add(x => x.ActionInProgress, true));
Assert.True(cut.Find("button[data-test=flow-step-retry]").HasAttribute("disabled"));
Assert.True(cut.Find("button[data-test=flow-step-discard]").HasAttribute("disabled"));
}
[Fact]
public void ParkedStep_WithoutCallbacks_ShowsOnlyTheHintLink()
{
var c = Guid.NewGuid();
var step = Step(FlowStepKind.Notify, FlowStepOutcome.Parked, "Planners", CachedLifecycle("Parked"), c);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
Assert.Empty(cut.FindAll("[data-test=flow-step-retry]"));
Assert.Empty(cut.FindAll("[data-test=flow-step-discard]"));
cut.Find("[data-test=flow-step-tab-lifecycle]").Click();
var hint = cut.Find("[data-test=flow-step-parked-hint]");
Assert.Contains("Parked", hint.TextContent);
var hintLink = hint.QuerySelector("a");
Assert.NotNull(hintLink);
Assert.Equal($"/notifications/report?correlationId={c:N}", hintLink!.GetAttribute("href"));
}
[Fact]
public void OkStep_NeverShowsActions()
{
var step = Step(FlowStepKind.ApiCall, FlowStepOutcome.Ok, "Delmia.AckRecipe", CachedLifecycle("Delivered"), Guid.NewGuid());
var cut = Render<FlowStepDetail>(p => p
.Add(x => x.Step, step)
.Add(x => x.OnRetry, _ => { })
.Add(x => x.OnDiscard, _ => { }));
Assert.Empty(cut.FindAll("[data-test=flow-step-retry]"));
Assert.Empty(cut.FindAll("[data-test=flow-step-discard]"));
}
[Fact]
public void NoLinesAndNoDetail_HidesTheLogTab()
{
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid())));
Assert.Empty(cut.FindAll("[data-test=flow-step-tab-log]"));
}
[Fact]
public void LogTab_CountsItsLines()
{
var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(),
[
Line("info", "Recipe 42 requested", seq: 1),
Line("debug", "Writing header attributes", seq: 2),
Line("warn", "ResultCode 7 is outside the accepted set", seq: 3),
]);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
Assert.Equal("Log (3)", cut.Find("[data-test=flow-step-tab-log]").TextContent.Trim());
}
[Fact]
public void FailedStepWithDetail_OpensOnLog_AndRendersTheExceptionBlock()
{
var step = ScriptStep(FlowStepOutcome.Failed, "ProcessRecipeDownload", Guid.NewGuid(),
[Line("error", "Rejecting recipe 42 (code 7)", seq: 1)], ExceptionDetail);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-log]").ClassName);
var exc = cut.Find("[data-test=flow-step-exception]");
Assert.Equal("System.InvalidOperationException", exc.QuerySelector(".fsd-exc-type")!.TextContent.Trim());
Assert.Equal("Recipe 42 rejected by Galaxy (code 7)", exc.QuerySelector(".fsd-exc-message")!.TextContent.Trim());
Assert.Contains("ScriptRunLauncher.RunScriptAsync", exc.QuerySelector("pre")!.TextContent);
Assert.NotNull(cut.Find("[data-test=flow-step-exception-copy]"));
}
[Fact]
public void OkStepWithLines_OpensOnInput()
{
var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(), [Line("info", "hello", seq: 1)]);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-input]").ClassName);
Assert.DoesNotContain("active", cut.Find("[data-test=flow-step-tab-log]").ClassName);
Assert.Empty(cut.FindAll("[data-test=flow-step-exception]"));
}
[Fact]
public void WarnFilter_LeavesOnlyWarnAndErrorRows()
{
var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(),
[
Line("info", "Recipe 42 requested", seq: 1),
Line("debug", "Writing header attributes", seq: 2),
Line("warn", "ResultCode 7 is outside the accepted set", seq: 3),
Line("error", "Rejecting recipe 42", seq: 4),
]);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
cut.Find("[data-test=flow-step-tab-log]").Click();
Assert.Equal(4, cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]").Count);
cut.Find("[data-test=flow-step-log-filter-warn]").Click();
var rows = cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]");
Assert.Equal(2, rows.Count);
Assert.Equal("warn", rows[0].GetAttribute("data-level"));
Assert.Equal("error", rows[1].GetAttribute("data-level"));
cut.Find("[data-test=flow-step-log-filter-all]").Click();
Assert.Equal(4, cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]").Count);
}
[Fact]
public void Hint_NamesAnotherStepOfTheSameExecution_AndSelectsIt()
{
var exec = Guid.NewGuid();
var selected = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", exec, [Line("info", "a", seq: 1)]);
var other = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId, RecipeName", exec, [Line("warn", "acked Uncertain", seq: 2)]);
var elsewhere = ScriptStep(FlowStepOutcome.Ok, "Another execution", Guid.NewGuid(), [Line("info", "c", seq: 3)]);
var graph = new FlowGraph([selected, other, elsewhere], [], false, false);
FlowStep? picked = null;
var cut = Render<FlowStepDetail>(p => p
.Add(x => x.Step, selected)
.Add(x => x.Graph, graph)
.Add(x => x.OnSelectStep, s => picked = s));
cut.Find("[data-test=flow-step-tab-log]").Click();
var hint = cut.Find("[data-test=flow-step-log-hint]");
Assert.Contains("1 more on", hint.TextContent);
Assert.Contains("Write RecipeId, RecipeName", hint.TextContent);
Assert.DoesNotContain("Another execution", hint.TextContent);
hint.QuerySelector("button")!.Click();
Assert.Same(other, picked);
}
[Fact]
public void Hint_WithoutASelectCallback_IsPlainText()
{
var exec = Guid.NewGuid();
var selected = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", exec, [Line("info", "a", seq: 1)]);
var other = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId, RecipeName", exec, [Line("warn", "b", seq: 2)]);
var graph = new FlowGraph([selected, other], [], false, false);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, selected).Add(x => x.Graph, graph));
cut.Find("[data-test=flow-step-tab-log]").Click();
var hint = cut.Find("[data-test=flow-step-log-hint]");
Assert.Contains("Write RecipeId, RecipeName", hint.TextContent);
Assert.Null(hint.QuerySelector("button"));
}
[Fact]
public void OutputAlert_SeeExceptionLink_SwitchesToTheLogTab()
{
var step = ScriptStep(FlowStepOutcome.Failed, "ProcessRecipeDownload", Guid.NewGuid(),
[Line("error", "Rejecting recipe 42", seq: 1)], ExceptionDetail);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
cut.Find("[data-test=flow-step-tab-output]").Click();
Assert.Contains("Recipe 42 rejected", cut.Find("[data-test=flow-step-error]").TextContent);
cut.Find("[data-test=flow-step-error-see-log]").Click();
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-log]").ClassName);
Assert.NotNull(cut.Find("[data-test=flow-step-exception]"));
}
[Fact]
public void RuntimeSourcedLine_ShowsItsSource_AndAnAttachedException()
{
var step = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId", Guid.NewGuid(),
[
Line("warn", "acked with quality Uncertain", source: "runtime", seq: 1,
exceptionMessage: "Tag write was not confirmed", exceptionDetail: "System.TimeoutException: Tag write was not confirmed\n at Dcl"),
]);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
cut.Find("[data-test=flow-step-tab-log]").Click();
var row = cut.Find("[data-test=flow-step-log-lines] tr[data-level=warn]");
Assert.Contains("runtime", row.TextContent);
Assert.Contains("Tag write was not confirmed", row.QuerySelector(".fsd-line-exc")!.TextContent);
Assert.Contains("System.TimeoutException", row.QuerySelector("details pre")!.TextContent);
}
}