feat(central-ui): FlowGraphView External lane, outbound status pills, async/spawn connectors
Four lanes (LaneCount 4; External sublabel = distinct targets, three then +n); ApiCall/DbCall/Notify nodes carry a status pill (Ok/Pending/Parked/Failed — pending pulses, reduced-motion aware) plus a sync/cached/notify mode; folded lifecycles show '+4.1 s' submit→terminal wall time. Async edges are dashed and routed through the inter-lane strip with a gutter tag; spawn edges use a bolt marker. Predicted async edges (Task 6's PredictedEdge.Async) render the same way. de-DE height pinned at 319.2 = (4×110+16)×0.7. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -142,11 +142,12 @@ public class FlowGraphViewTests : BunitContext
|
||||
{
|
||||
var cut = Render<FlowGraphView>();
|
||||
|
||||
Assert.Equal(3, cut.FindAll("g.fg-lane").Count);
|
||||
Assert.Equal(4, cut.FindAll("g.fg-lane").Count);
|
||||
Assert.NotNull(cut.Find("[data-test='flow-graph-empty']"));
|
||||
Assert.Empty(cut.FindAll("g[data-test='flow-node']"));
|
||||
Assert.Contains("Central", cut.Markup);
|
||||
Assert.Contains("Tags", cut.Markup);
|
||||
Assert.Contains("External", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -204,8 +205,9 @@ public class FlowGraphViewTests : BunitContext
|
||||
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, DelmiaGraph()).Add(c => c.Zoom, 0.7));
|
||||
|
||||
var svg = cut.Find("svg");
|
||||
// 346 × 0.7 = 242.2 — a comma here would be the German decimal separator leaking through.
|
||||
Assert.Equal("242.2", svg.GetAttribute("height"));
|
||||
// Four lanes: (4 × 110 + 16) × 0.7 = 456 × 0.7 = 319.2 — a comma here would be the German decimal separator leaking through.
|
||||
Assert.Equal((4 * FlowGraphView.LaneHeight + FlowGraphView.LanePad) * 0.7, 319.2, 9);
|
||||
Assert.Equal("319.2", svg.GetAttribute("height"));
|
||||
Assert.DoesNotContain(",", svg.GetAttribute("viewBox"));
|
||||
foreach (var path in cut.FindAll("path[data-test='flow-edge']"))
|
||||
Assert.DoesNotContain(",", path.GetAttribute("d"));
|
||||
@@ -301,4 +303,198 @@ public class FlowGraphViewTests : BunitContext
|
||||
// The gutter sublabel fits without clipping at the widened gutter.
|
||||
Assert.Contains("site-a · DelmiaSim01", cut.Markup.Replace("·", "·"));
|
||||
}
|
||||
|
||||
// ── Outbound calls (plan 2026-09-06): External lane, status pills, async/spawn connectors ──
|
||||
|
||||
private static readonly Guid SiteRun = Guid.NewGuid();
|
||||
private static readonly Guid Cascade = Guid.NewGuid();
|
||||
|
||||
private static AuditEvent Outbound(AuditChannel channel, AuditKind kind, AuditStatus status, int ms, Guid? correlation, string target,
|
||||
string node = "node-a", string? error = null, int? durationMs = null)
|
||||
=> ScadaBridgeAuditEventFactory.Create(channel, kind, status,
|
||||
occurredAtUtc: T0.AddMilliseconds(ms), actor: "S", target: target, sourceNode: node, correlationId: correlation,
|
||||
executionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", errorMessage: error, durationMs: durationMs);
|
||||
|
||||
private static AuditEvent WriteRow(int ms, string attribute) => ScadaBridgeAuditEventFactory.Create(
|
||||
AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(ms), target: "M1." + attribute, executionId: SiteRun, parentExecutionId: Root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 1,
|
||||
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("M1." + attribute, "1", true, null)] }.ToJson());
|
||||
|
||||
/// <summary>
|
||||
/// Inbound → route → site run → write A → sync API call → write B → (spawned cascade run), with a
|
||||
/// pending cached DB write and a parked notification branching off the run.
|
||||
/// </summary>
|
||||
private static FlowGraph OutboundGraph(bool fourthTarget = false)
|
||||
{
|
||||
var events = new List<AuditEvent>
|
||||
{
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0, target: "POST /api/FlowOutboundDemo", executionId: Root, durationMs: 900, responseSummary: "{}"),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(5), target: "M1.OutboundDemo", executionId: Root, durationMs: 850,
|
||||
extra: new ScriptTraceExtra { ChildExecutionId = SiteRun }.ToJson()),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(20), target: "OutboundDemo", executionId: SiteRun, parentExecutionId: Root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 800),
|
||||
WriteRow(30, "A"),
|
||||
Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, 35, null, "Delmia.AckRecipe", durationMs: 212),
|
||||
Outbound(AuditChannel.DbOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, 36, Guid.NewGuid(), "MesDb"),
|
||||
WriteRow(40, "B"),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(45), target: "OnBChanged", executionId: Cascade, parentExecutionId: SiteRun,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 10,
|
||||
extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "M1.B", TriggerValue = "1" }.ToJson()),
|
||||
};
|
||||
var notificationId = Guid.NewGuid();
|
||||
events.Add(Outbound(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted, 37, notificationId, "Planners"));
|
||||
events.Add(Outbound(AuditChannel.Notification, AuditKind.NotifyDeliver, AuditStatus.Parked, 9002, notificationId, "Planners", node: "central-a", error: "retries exhausted"));
|
||||
if (fourthTarget)
|
||||
events.Add(Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, 38, null, "Other.Thing", durationMs: 5));
|
||||
return FlowGraphBuilder.Build(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Layout_OutboundSteps_LandInTheExternalLane_WithATargetSublabel()
|
||||
{
|
||||
var layout = FlowGraphView.ComputeLayout(OutboundGraph(), null);
|
||||
|
||||
var external = layout.Nodes.Where(n => n.Lane == FlowLane.External).ToList();
|
||||
Assert.Equal(3, external.Count);
|
||||
Assert.Equal([FlowStepKind.ApiCall, FlowStepKind.DbCall, FlowStepKind.Notify], external.Select(n => n.Kind));
|
||||
Assert.All(external, n => Assert.Equal(FlowGraphView.LanePad + 3 * FlowGraphView.LaneHeight, n.Y));
|
||||
Assert.Equal("Delmia.AckRecipe · MesDb · Planners", layout.LaneSublabels[(int)FlowLane.External]);
|
||||
Assert.Equal(4 * FlowGraphView.LaneHeight + FlowGraphView.LanePad, layout.Height);
|
||||
// The edge flags ride through from the graph.
|
||||
Assert.Equal(2, layout.Edges.Count(e => e.Async));
|
||||
Assert.Single(layout.Edges, e => e.Spawn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Layout_ExternalSublabel_ListsThreeTargetsThenCounts()
|
||||
{
|
||||
var layout = FlowGraphView.ComputeLayout(OutboundGraph(fourthTarget: true), null);
|
||||
|
||||
Assert.Equal("Delmia.AckRecipe · MesDb · Planners +1", layout.LaneSublabels[(int)FlowLane.External]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ApiCall_HasEyebrowAndOkPillWithSyncMode()
|
||||
{
|
||||
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, OutboundGraph()));
|
||||
|
||||
Assert.Equal(4, cut.FindAll("g.fg-lane").Count);
|
||||
var api = cut.Find("g[data-test='flow-node'][data-kind='ApiCall']");
|
||||
Assert.Equal("External", api.GetAttribute("data-lane"));
|
||||
Assert.Equal("API CALL", api.QuerySelector(".fg-eyebrow")!.TextContent.Trim());
|
||||
Assert.Equal("212 ms", api.QuerySelector(".fg-duration")!.TextContent.Trim());
|
||||
var pill = api.QuerySelector("[data-test='flow-node-pill']")!;
|
||||
Assert.Equal("Ok", pill.TextContent.Trim());
|
||||
Assert.Contains("ok", pill.ClassList);
|
||||
Assert.Equal("· sync", api.QuerySelector("[data-test='flow-node-status'] .fg-mode")!.TextContent.Trim());
|
||||
Assert.DoesNotContain("pending", api.ClassList);
|
||||
Assert.DoesNotContain("parked", api.ClassList);
|
||||
// Non-outbound nodes keep the plain subtitle line and get no pill.
|
||||
var run = cut.Find("g[data-test='flow-node'][data-kind='ScriptRun']");
|
||||
Assert.Null(run.QuerySelector("[data-test='flow-node-pill']"));
|
||||
Assert.NotNull(run.QuerySelector("[data-test='flow-node-subtitle']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_PendingCachedCall_HasPendingClassAndPill()
|
||||
{
|
||||
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, OutboundGraph()));
|
||||
|
||||
var db = cut.Find("g[data-test='flow-node'][data-kind='DbCall']");
|
||||
Assert.Contains("pending", db.ClassList);
|
||||
Assert.Equal("DATABASE", db.QuerySelector(".fg-eyebrow")!.TextContent.Trim());
|
||||
var pill = db.QuerySelector("[data-test='flow-node-pill']")!;
|
||||
Assert.Equal("Pending", pill.TextContent.Trim());
|
||||
Assert.Contains("pending", pill.ClassList);
|
||||
Assert.Equal("· cached", db.QuerySelector("[data-test='flow-node-status'] .fg-mode")!.TextContent.Trim());
|
||||
Assert.Equal(string.Empty, db.QuerySelector(".fg-duration")!.TextContent.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ParkedNotification_HasParkedClass_AndSubmitToTerminalDuration()
|
||||
{
|
||||
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, OutboundGraph()));
|
||||
|
||||
var notify = cut.Find("g[data-test='flow-node'][data-kind='Notify']");
|
||||
Assert.Contains("parked", notify.ClassList);
|
||||
Assert.Equal("NOTIFY", notify.QuerySelector(".fg-eyebrow")!.TextContent.Trim());
|
||||
var pill = notify.QuerySelector("[data-test='flow-node-pill']")!;
|
||||
Assert.Equal("Parked", pill.TextContent.Trim());
|
||||
Assert.Contains("parked", pill.ClassList);
|
||||
Assert.Equal("· notify", notify.QuerySelector("[data-test='flow-node-status'] .fg-mode")!.TextContent.Trim());
|
||||
// A folded lifecycle shows submit→terminal wall time with a leading "+" (8965 ms → "+9 s").
|
||||
Assert.Equal("+9 s", notify.QuerySelector(".fg-duration")!.TextContent.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_AsyncEdgesAreDashedClass_SpawnEdgeUsesTheBoltMarker()
|
||||
{
|
||||
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, OutboundGraph()));
|
||||
|
||||
Assert.NotNull(cut.Find("marker#fg-arrow-spawn"));
|
||||
var edges = cut.FindAll("path[data-test='flow-edge']");
|
||||
var async = edges.Where(e => e.ClassList.Contains("async")).ToList();
|
||||
Assert.Equal(2, async.Count);
|
||||
Assert.All(async, e => Assert.Equal("url(#fg-arrow)", e.GetAttribute("marker-end")));
|
||||
Assert.All(async, e => Assert.DoesNotContain("external", e.ClassList));
|
||||
var spawn = Assert.Single(edges, e => e.ClassList.Contains("spawn"));
|
||||
Assert.Equal("url(#fg-arrow-spawn)", spawn.GetAttribute("marker-end"));
|
||||
Assert.All(edges.Where(e => !e.ClassList.Contains("spawn")), e => Assert.NotEqual("url(#fg-arrow-spawn)", e.GetAttribute("marker-end")));
|
||||
Assert.All(edges, e => Assert.DoesNotContain(",", e.GetAttribute("d")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FlowStepKind.ApiCall, "Delivered · sync", "sync")]
|
||||
[InlineData(FlowStepKind.ApiCall, "Failed · sync", "sync")]
|
||||
[InlineData(FlowStepKind.ApiCall, "Delivered · cached", "cached")]
|
||||
[InlineData(FlowStepKind.DbCall, "Submitted · cached", "cached")]
|
||||
[InlineData(FlowStepKind.DbCall, "write · 3 rows", "write · 3 rows · sync")]
|
||||
[InlineData(FlowStepKind.DbCall, "Delivered", "sync")]
|
||||
[InlineData(FlowStepKind.DbCall, null, "sync")]
|
||||
[InlineData(FlowStepKind.Notify, "Parked", "notify")]
|
||||
[InlineData(FlowStepKind.Notify, null, "notify")]
|
||||
public void OutboundMode_DropsTheStatusThePillCarries_AndNamesTheCallMode(FlowStepKind kind, string? subtitle, string expected)
|
||||
{
|
||||
Assert.Equal(expected, FlowGraphView.OutboundMode(kind, subtitle));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_PredictedOutboundNodes_AreGhostsWithoutPills()
|
||||
{
|
||||
var predicted = new PredictedFlow(
|
||||
[
|
||||
new("n1", FlowStepKind.ScriptRun, FlowLane.Site, "OutboundDemo", false, null),
|
||||
new("n2", FlowStepKind.ApiCall, FlowLane.External, "Delmia.AckRecipe", false, "sync"),
|
||||
new("n3", FlowStepKind.Notify, FlowLane.External, "Notify Planners", false, "async"),
|
||||
], [new("n1", "n2", false), new("n1", "n3", false)]);
|
||||
|
||||
var cut = Render<FlowGraphView>(p => p.Add(c => c.Predicted, predicted));
|
||||
|
||||
var external = cut.FindAll("g[data-test='flow-node'][data-lane='External']");
|
||||
Assert.Equal(2, external.Count);
|
||||
Assert.All(external, n => Assert.Contains("predicted", n.ClassList));
|
||||
Assert.All(external, n => Assert.Null(n.QuerySelector("[data-test='flow-node-pill']")));
|
||||
Assert.Equal("API CALL", external[0].QuerySelector(".fg-eyebrow")!.TextContent.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PredictedAsyncEdge_RendersWithAsyncClass()
|
||||
{
|
||||
var flow = new PredictedFlow(
|
||||
[
|
||||
new("n1", FlowStepKind.ScriptRun, FlowLane.Site, "S", false, null),
|
||||
new("n2", FlowStepKind.Notify, FlowLane.External, "Notify Planners", false, null),
|
||||
],
|
||||
[new("n1", "n2", false, Async: true)]);
|
||||
|
||||
var layout = FlowGraphView.ComputeLayout(null, flow);
|
||||
|
||||
var edge = Assert.Single(layout.Edges);
|
||||
Assert.True(edge.Async);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user