feat(central-ui): FlowStepDetail Lifecycle tab + outbound request/response labels

A folded outbound step shows a Lifecycle tab (time · kind · status · node ·
error, oldest first; a Parked step adds the 'retry or discard from Site Calls /
Notification report' hint), replaces the never-populated Writes tab for those
steps, labels the Input tab Request / SQL + parameters / Subject + body by kind,
and 'Where this ran' names the System / Connection / List plus 'Delivered by'
(the terminal row's node). Pending/Parked outcome badges.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 01:59:55 -04:00
parent 56154fd1d2
commit 8f6f7ed92b
3 changed files with 171 additions and 8 deletions
@@ -0,0 +1,94 @@
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.
/// </summary>
public class FlowStepDetailTests : BunitContext
{
private static readonly DateTimeOffset T0 = new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero);
private static FlowStep Step(FlowStepKind kind, FlowStepOutcome outcome, string target, IReadOnlyList<FlowLifecycleRow>? lifecycle = 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);
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());
}
}