diff --git a/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json b/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json index 5924f484..4625d9ad 100644 --- a/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json +++ b/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json @@ -34,7 +34,7 @@ { "id": 4, "subject": "Task 4: Central \u2014 audit InboundDatabaseHelper (DbOutbound) and feed the trace sink", - "status": "in_progress", + "status": "completed", "blockedBy": [ 0 ] @@ -50,7 +50,7 @@ { "id": 6, "subject": "Task 6: FlowShapePredictor \u2014 three kinds on the External lane, async edges", - "status": "in_progress", + "status": "completed", "blockedBy": [ 0 ] @@ -96,5 +96,5 @@ ] } ], - "lastUpdated": "2026-09-06T05:51:19.355720+00:00" + "lastUpdated": "2026-09-06T05:52:50.321199+00:00" } \ No newline at end of file diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor index 95a8c399..3be9dd4f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor @@ -2,9 +2,10 @@ @using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging @using ZB.MOM.WW.ScadaBridge.ScriptAnalysis -@* Flow Debugger swimlane (design 2026-09-05 §4). Dependency-free inline SVG: - three fixed lanes, one node per step, orthogonal connectors. All coordinate - math lives in the code-behind (InvariantCulture throughout). *@ +@* Flow Debugger swimlane (design 2026-09-05 §4; External lane, status pills and async/spawn + connectors per the 2026-09-06 outbound-calls plan). Dependency-free inline SVG: four fixed + lanes, one node per step, orthogonal connectors. All coordinate math lives in the + code-behind (InvariantCulture throughout). *@
+ @* Trigger spawn (plan D9): a small bolt in place of the arrowhead — this write fired a value-change script. *@ + + + @* Lanes *@ - @for (var lane = 0; lane < 3; lane++) + @for (var lane = 0; lane < LaneCount; lane++) { var i = lane; @@ -47,6 +52,11 @@ @foreach (var e in _layout.Edges) { + @if (AsyncTagFor(e) is { } tag) + { + @* Names the branch where it lands: the run carried on without waiting for this. *@ + @SvgText(tag.X, tag.Y, "fg-edge-tag", "async", dataTest: "flow-edge-tag") + } } @* Nodes *@ @@ -69,7 +79,18 @@ @Duration(node)
@WrapHints(node.DisplayTitle)
-
@node.DisplaySubtitle
+ @if (HasPill(node)) + { + @* Outbound kinds: the folded outcome as a pill, then the call mode (node anatomy: "● Ok · sync"). *@ +
+ @PillText(node.Step.Outcome) + · @OutboundMode(node.Kind, node.Subtitle) +
+ } + else + { +
@node.DisplaySubtitle
+ } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs index e268cd3b..4b566d42 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs @@ -54,7 +54,9 @@ public sealed record FlowLayoutNode( /// Accent colour — a central↔site hop. /// Dashed — an external actor (the tag source responding). /// Light — an edge of the prediction, not of a run. -public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly); +/// Dashed — a fire-and-forget branch (cached call / notification) the source never waited for (plan D4). +/// Bolt marker — the target is the first step of a trigger-spawned child execution (plan D9). +public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly, bool Async = false, bool Spawn = false); /// A computed swimlane layout. /// Nodes with coordinates. @@ -76,12 +78,13 @@ public sealed record FlowLayout( } /// -/// Custom-SVG swimlane renderer for the Flow Debugger (design 2026-09-05 §4). Three fixed lanes -/// (Central / Site / Tags), columns from a topological pass over the graph's edges, orthogonal -/// connectors. Renders a real , a , or both — -/// in which case predicted nodes the run never produced are greyed. The layout is a pure -/// function () so it is unit-tested without rendering; every -/// coordinate string is -formatted. +/// Custom-SVG swimlane renderer for the Flow Debugger (design 2026-09-05 §4). Four fixed lanes +/// (Central / Site / Tags / External — the fourth added by the 2026-09-06 outbound-calls plan, D2), +/// columns from a topological pass over the graph's edges, orthogonal connectors. Renders a real +/// , a , or both — in which case predicted nodes +/// the run never produced are greyed. The layout is a pure function () +/// so it is unit-tested without rendering; every coordinate string is +/// -formatted. /// public partial class FlowGraphView : ComponentBase { @@ -100,7 +103,10 @@ public partial class FlowGraphView : ComponentBase /// Right-hand canvas padding. public const double RightPad = 20; - private const int LaneCount = 3; + /// Number of swimlanes ( members, in order). + public const int LaneCount = 4; + /// Most distinct targets the External gutter names before folding the rest into +n. + public const int ExternalSublabelTargets = 3; /// The real graph (from a run or a replay); null before a run. [Parameter] public FlowGraph? Graph { get; set; } @@ -160,6 +166,8 @@ public partial class FlowGraphView : ComponentBase case FlowStepOutcome.Failed: sb.Append(" failed"); break; case FlowStepOutcome.TimedOut: sb.Append(" timed-out"); break; case FlowStepOutcome.NotTraced: sb.Append(" not-traced"); break; + case FlowStepOutcome.Pending: sb.Append(" pending"); break; + case FlowStepOutcome.Parked: sb.Append(" parked"); break; } return sb.ToString(); @@ -170,12 +178,57 @@ public partial class FlowGraphView : ComponentBase var sb = new StringBuilder("fg-edge"); if (e.CrossesBoundary) sb.Append(" boundary"); if (e.External) sb.Append(" external"); + if (e.Async) sb.Append(" async"); + if (e.Spawn) sb.Append(" spawn"); if (e.PredictedOnly) sb.Append(" predicted"); return sb.ToString(); } private static string MarkerFor(FlowLayoutEdge e) - => e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)"; + => e.Spawn ? "url(#fg-arrow-spawn)" : e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)"; + + /// True for a real outbound step (API call / database / notification) — the kinds that carry a status pill instead of a subtitle line. + private static bool HasPill(FlowLayoutNode n) + => n.Step != null && n.Kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify; + + /// Pill wording for an outbound step's folded outcome (plan D3: the latest lifecycle row decides). + public static string PillText(FlowStepOutcome outcome) => outcome switch + { + FlowStepOutcome.Ok => "Ok", + FlowStepOutcome.Pending => "Pending", + FlowStepOutcome.Parked => "Parked", + FlowStepOutcome.Failed => "Failed", + FlowStepOutcome.TimedOut => "Timed out", + FlowStepOutcome.NotTraced => "Not traced", + _ => outcome.ToString(), + }; + + private static string PillClass(FlowStepOutcome outcome) => outcome switch + { + FlowStepOutcome.Ok => "fg-pill ok", + FlowStepOutcome.Pending => "fg-pill pending", + FlowStepOutcome.Parked => "fg-pill parked", + FlowStepOutcome.Failed or FlowStepOutcome.TimedOut => "fg-pill failed", + _ => "fg-pill", + }; + + /// + /// The text after the pill: sync / cached / notify per the node anatomy, with a + /// sync database call's operation detail (write · 3 rows) kept in front of it. The projector's + /// subtitle already leads with the audit status, which the pill now carries, so that head is dropped. + /// + public static string OutboundMode(FlowStepKind kind, string? subtitle) + { + if (kind == FlowStepKind.Notify) return "notify"; + var detail = subtitle ?? string.Empty; + // Strip a leading "{AuditStatus} · " / a bare status word (the pill says it). + var head = detail.Split(" · ", 2, StringSplitOptions.None); + if (head.Length > 0 && Enum.TryParse(head[0], ignoreCase: false, out _)) + detail = head.Length == 2 ? head[1] : string.Empty; + if (detail is "cached" or "sync") return detail; + if (detail.EndsWith(" · cached", StringComparison.Ordinal) || detail.EndsWith(" · sync", StringComparison.Ordinal)) return detail; + return detail.Length == 0 ? "sync" : detail + " · sync"; + } private static string Eyebrow(FlowLayoutNode n) => n.Kind switch { @@ -191,8 +244,21 @@ public partial class FlowGraphView : ComponentBase _ => "STEP", }; - private static string Duration(FlowLayoutNode n) - => n.Step?.DurationMs is int ms ? ms.ToString(CultureInfo.InvariantCulture) + " ms" : (n.PredictedOnly ? "predicted" : string.Empty); + /// + /// The head-row duration: a step's own milliseconds, or — for a folded outbound lifecycle of more + /// than one row — the submit→terminal wall time with a leading + (+4.1 s), since that + /// span is elapsed time downstream of the script, not time the script spent (node anatomy). + /// + public static string Duration(FlowLayoutNode n) + { + if (n.Step?.DurationMs is not int ms) + return n.PredictedOnly ? "predicted" : string.Empty; + if (n.Step.Lifecycle is { Count: > 1 }) + return ms < 1000 + ? "+" + ms.ToString(CultureInfo.InvariantCulture) + " ms" + : "+" + (ms / 1000.0).ToString("0.#", CultureInfo.InvariantCulture) + " s"; + return ms.ToString(CultureInfo.InvariantCulture) + " ms"; + } /// Clips a gutter label to what fits, keeping the full text in a title. public static string Clip(string? text, int max) @@ -274,6 +340,17 @@ public partial class FlowGraphView : ComponentBase return new MarkupString(sb.ToString()); } + /// + /// Where an async branch's async tag sits: just right of the drop into the target's top, in + /// the strip between lanes (mirrors the mockup). Null for every non-async edge. + /// + private (string X, string Y)? AsyncTagFor(FlowLayoutEdge e) + { + if (!e.Async) return null; + var b = _layout.Nodes.FirstOrDefault(n => n.Key == e.To); + return b is null ? null : (F(b.X + 22), F(b.Y - 4)); + } + private Task SelectAsync(FlowLayoutNode n) => n.Step != null && OnSelect.HasDelegate ? OnSelect.InvokeAsync(n.Step) : Task.CompletedTask; @@ -299,7 +376,8 @@ public partial class FlowGraphView : ComponentBase var keys = new List(); var lane = new Dictionary(StringComparer.Ordinal); var preds = new Dictionary>(StringComparer.Ordinal); - var rawEdges = new List<(string From, string To, bool Crosses, bool External, bool PredictedOnly)>(); + var rawEdges = new List<(string From, string To, bool Crosses, bool External, bool PredictedOnly, bool Async, bool Spawn)>(); + var externalTargets = new List(); var stepByKey = new Dictionary(StringComparer.Ordinal); var predByKey = new Dictionary(StringComparer.Ordinal); var ghostKeys = new HashSet(StringComparer.Ordinal); @@ -316,14 +394,18 @@ public partial class FlowGraphView : ComponentBase sublabels[(int)FlowLane.Site] = JoinLabel(s.SiteId, s.Instance); if (s.Lane == FlowLane.Tags && string.IsNullOrEmpty(sublabels[(int)FlowLane.Tags])) sublabels[(int)FlowLane.Tags] = s.Instance ?? string.Empty; + if (s.Lane == FlowLane.External && !string.IsNullOrWhiteSpace(s.Target) && !externalTargets.Contains(s.Target, StringComparer.Ordinal)) + externalTargets.Add(s.Target); } + sublabels[(int)FlowLane.External] = ExternalSublabel(externalTargets); + foreach (var e in graph.Edges) { var from = "e:" + e.FromEventId.ToString("N"); var to = "e:" + e.ToEventId.ToString("N"); if (lane.ContainsKey(from) && lane.ContainsKey(to)) - rawEdges.Add((from, to, e.CrossesBoundary, e.External, false)); + rawEdges.Add((from, to, e.CrossesBoundary, e.External, false, e.Async, e.Spawn)); } if (hasPredicted) @@ -365,11 +447,11 @@ public partial class FlowGraphView : ComponentBase var from = "p:" + e.From; var to = "p:" + e.To; if (lane.ContainsKey(from) && lane.ContainsKey(to)) - rawEdges.Add((from, to, e.CrossesBoundary, false, true)); + rawEdges.Add((from, to, e.CrossesBoundary, false, true, e.Async, false)); } } - foreach (var (from, to, _, _, _) in rawEdges) + foreach (var (from, to, _, _, _, _, _) in rawEdges) { if (!preds.TryGetValue(to, out var list)) preds[to] = list = []; @@ -444,11 +526,12 @@ public partial class FlowGraphView : ComponentBase } var byKey = nodes.ToDictionary(n => n.Key, StringComparer.Ordinal); - foreach (var (from, to, crosses, external, predictedOnly) in rawEdges) + foreach (var (from, to, crosses, external, predictedOnly, isAsync, spawn) in rawEdges) { var a = byKey[from]; var b = byKey[to]; - edges.Add(new FlowLayoutEdge(from, to, OrthogonalPath(a, b), crosses, external, predictedOnly)); + var path = isAsync ? AsyncPath(a, b) : OrthogonalPath(a, b); + edges.Add(new FlowLayoutEdge(from, to, path, crosses, external, predictedOnly, isAsync, spawn)); } var width = ColumnOrigin + (maxCol + 1) * ColumnPitch + RightPad; @@ -463,6 +546,36 @@ public partial class FlowGraphView : ComponentBase private static string JoinLabel(string? a, string? b) => string.IsNullOrEmpty(a) ? b ?? string.Empty : string.IsNullOrEmpty(b) ? a : $"{a} · {b}"; + /// + /// The External gutter: the distinct targets touched, in first-seen order, joined with · — + /// up to named, the rest folded into +n + /// (Delmia.AckRecipe · MesDb · Planners +1). Empty when the lane is unused. + /// + public static string ExternalSublabel(IReadOnlyList distinctTargets) + { + if (distinctTargets.Count == 0) return string.Empty; + var shown = string.Join(" · ", distinctTargets.Take(ExternalSublabelTargets)); + var rest = distinctTargets.Count - ExternalSublabelTargets; + return rest > 0 ? $"{shown} +{rest.ToString(CultureInfo.InvariantCulture)}" : shown; + } + + /// + /// Connector for an async branch (cached call / notification): leaves the source's bottom-centre, + /// runs along the strip above the target's lane and drops into the target's top — so it never + /// crosses a node sitting between the two lanes. Falls back to when + /// the target is not below the source. + /// + public static string AsyncPath(FlowLayoutNode a, FlowLayoutNode b) + { + if (b.Y <= a.Y) + return OrthogonalPath(a, b); + var x1 = a.X + NodeWidth / 2; + var y1 = a.Y + NodeHeight; + var strip = b.Y - LanePad / 2; + var x2 = b.X + 16; + return $"M {F(x1)} {F(y1)} V {F(strip)} H {F(x2)} V {F(b.Y)}"; + } + /// /// Orthogonal connector from the right-middle of to the left-middle /// of : straight when in the same lane, otherwise right-down/up-right diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css index f6cf74ff..9a4e2f11 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css @@ -46,6 +46,7 @@ .fg-node.failed .fg-node-box, .fg-node.timed-out .fg-node-box { stroke: var(--bs-danger, #c0392b); } .fg-node.not-traced .fg-node-box { stroke-dasharray: 1 3; } +.fg-node.parked .fg-node-box { stroke: var(--warn, var(--bs-warning, #e8920c)); } /* Node body: HTML inside a foreignObject so text is measured by the browser. */ .fg-body { @@ -77,6 +78,40 @@ .fg-node.failed .fg-eyebrow, .fg-node.timed-out .fg-eyebrow { color: var(--bs-danger, #c0392b); } +/* Outbound nodes (API call / database / notify): the folded outcome as a status pill, then the + call mode. Kit status tokens first (dark mode follows via site.css), Bootstrap's as fallback. */ +.fg-status { margin-top: auto; display: flex; align-items: center; gap: 4px; min-width: 0; } +.fg-pill { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + font-size: 0.6rem; + font-weight: 500; + line-height: 1.3; + padding: 0 6px; + border-radius: 999px; + white-space: nowrap; + color: var(--ink-soft); + background: var(--rule); +} +.fg-pill::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; } +.fg-pill.ok { color: var(--ok, var(--bs-success, #2f9e44)); background: var(--ok-bg, rgba(var(--bs-success-rgb, 25, 135, 84), 0.12)); } +.fg-pill.pending { color: var(--warn, var(--bs-warning, #e8920c)); background: var(--warn-bg, rgba(var(--bs-warning-rgb, 255, 193, 7), 0.15)); } +.fg-pill.parked { color: var(--warn, var(--bs-warning, #e8920c)); background: var(--warn-bg, rgba(var(--bs-warning-rgb, 255, 193, 7), 0.15)); } +.fg-pill.failed { color: var(--bad, var(--bs-danger, #e03131)); background: var(--bad-bg, rgba(var(--bs-danger-rgb, 220, 53, 69), 0.12)); } +.fg-mode { color: var(--ink-soft); font-size: 0.66rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* A pending pill pulses while the page keeps refreshing outcomes after a Live run. */ +.fg-node.pending .fg-pill { animation: fg-pulse 1.6s ease-in-out infinite; } +@keyframes fg-pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 var(--warn-bg, rgba(var(--bs-warning-rgb, 255, 193, 7), 0.15)); } + 50% { opacity: 0.6; box-shadow: 0 0 0 4px var(--warn-bg, rgba(var(--bs-warning-rgb, 255, 193, 7), 0.15)); } +} +@media (prefers-reduced-motion: reduce) { + .fg-node.pending .fg-pill { animation: none; } +} + /* Connectors */ .fg-edge { fill: none; @@ -85,6 +120,12 @@ } .fg-edge.boundary { stroke: var(--accent); } .fg-edge.external { stroke-dasharray: 5 4; } +/* Async branch (cached call / notification): same dash as the echo edge, its own class — the run + carried on without waiting. Spawn: solid line ending in the bolt marker (a trigger cascade). */ +.fg-edge.async { stroke-dasharray: 5 4; } +.fg-edge.spawn { stroke: var(--ink-soft); } .fg-edge.predicted { stroke-opacity: 0.5; stroke-dasharray: 3 3; } .fg-arrowhead { fill: var(--ink-faint); } .fg-arrowhead.accent { fill: var(--accent); } +.fg-arrowhead.spawn { fill: var(--ink-soft); } +.fg-wrap ::deep .fg-edge-tag { fill: var(--ink-faint); font-size: 0.6rem; font-family: var(--mono); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs index 15fa9b56..69ce06e8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs @@ -142,11 +142,12 @@ public class FlowGraphViewTests : BunitContext { var cut = Render(); - 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(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()); + + /// + /// 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. + /// + private static FlowGraph OutboundGraph(bool fourthTarget = false) + { + var events = new List + { + 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(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(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(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(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(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); + } }