diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor
new file mode 100644
index 00000000..6d85e98d
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor
@@ -0,0 +1,65 @@
+@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
+@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). *@
+
+
+
+
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
new file mode 100644
index 00000000..fd107bea
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs
@@ -0,0 +1,392 @@
+using System.Globalization;
+using System.Text;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Web;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
+using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
+
+/// One laid-out node of the swimlane: a real step, a predicted node, or both (matched).
+/// Stable render key (e:{eventId} for real steps, p:{id} for predicted-only nodes).
+/// Swimlane.
+/// Zero-based column from the topological pass.
+/// Left edge in SVG units.
+/// Top edge in SVG units.
+/// The real step, null for a predicted-only node.
+/// The predicted node, null for a real step with no prediction match.
+/// True for a predicted node the real graph never produced (greyed).
+public sealed record FlowLayoutNode(
+ string Key, FlowLane Lane, int Column, double X, double Y, FlowStep? Step, PredictedNode? Predicted, bool Ghost)
+{
+ /// True when this node comes from a prediction only (no real step yet).
+ public bool PredictedOnly => Step == null;
+
+ /// Display kind.
+ public FlowStepKind Kind => Step?.Kind ?? Predicted!.Kind;
+
+ /// Display title.
+ public string Title => Step?.Title ?? Predicted!.Title;
+
+ /// Display subtitle (real: the step's; predicted: the note).
+ public string? Subtitle => Step?.Subtitle ?? Predicted?.Note;
+}
+
+/// One laid-out connector.
+/// Source node key.
+/// Target node key.
+/// SVG path data (orthogonal, culture-invariant).
+/// 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);
+
+/// A computed swimlane layout.
+/// Nodes with coordinates.
+/// Connectors with path data.
+/// Canvas width in SVG units.
+/// Canvas height in SVG units.
+/// Per-lane sublabel (index = ), empty when unknown.
+public sealed record FlowLayout(
+ IReadOnlyList Nodes,
+ IReadOnlyList Edges,
+ double Width,
+ double Height,
+ IReadOnlyList LaneSublabels)
+{
+ /// True when there is nothing to draw.
+ public bool IsEmpty => Nodes.Count == 0;
+}
+
+///
+/// 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.
+///
+public partial class FlowGraphView : ComponentBase
+{
+ /// Lane height in SVG units.
+ public const double LaneHeight = 110;
+ /// Node width.
+ public const double NodeWidth = 150;
+ /// Node height.
+ public const double NodeHeight = 78;
+ /// Left edge of column 0 (the lane label gutter sits to its left).
+ public const double ColumnOrigin = 130;
+ /// Column pitch.
+ public const double ColumnPitch = 170;
+ /// Top padding inside a lane.
+ public const double LanePad = 16;
+ /// Right-hand canvas padding.
+ public const double RightPad = 20;
+
+ private const int LaneCount = 3;
+
+ /// The real graph (from a run or a replay); null before a run.
+ [Parameter] public FlowGraph? Graph { get; set; }
+
+ /// The predicted flow; drawn alone before a run, overlaid (ghosts) after one.
+ [Parameter] public PredictedFlow? Predicted { get; set; }
+
+ /// The selected real step's event id.
+ [Parameter] public Guid? SelectedEventId { get; set; }
+
+ /// Raised when a real step is clicked or activated with Enter/Space.
+ [Parameter] public EventCallback OnSelect { get; set; }
+
+ /// Zoom factor applied to the rendered size (the viewBox is fixed).
+ [Parameter] public double Zoom { get; set; } = 1.0;
+
+ private FlowLayout _layout = ComputeLayout(null, null);
+
+ ///
+ protected override void OnParametersSet()
+ {
+ _layout = ComputeLayout(Graph, Predicted);
+ }
+
+ /// Formats a coordinate culture-invariantly.
+ public static string F(double v) => v.ToString("0.##", CultureInfo.InvariantCulture);
+
+ private string RenderedWidth => F(_layout.Width * Math.Max(0.1, Zoom));
+ private string RenderedHeight => F(_layout.Height * Math.Max(0.1, Zoom));
+ private string ViewBox => $"0 0 {F(_layout.Width)} {F(_layout.Height)}";
+
+ private static string LaneName(int lane) => ((FlowLane)lane).ToString();
+ private static string LaneY(int lane) => F(lane * LaneHeight);
+ private static string LaneLabelY(int lane) => F(lane * LaneHeight + 24);
+
+ private string AriaSummary
+ {
+ get
+ {
+ if (_layout.IsEmpty) return "Flow diagram: no steps.";
+ var real = _layout.Nodes.Count(n => !n.PredictedOnly);
+ var predicted = _layout.Nodes.Count(n => n.PredictedOnly);
+ var crossings = _layout.Edges.Count(e => e.CrossesBoundary);
+ return $"Flow diagram: {real} steps, {predicted} predicted, {crossings} boundary crossings.";
+ }
+ }
+
+ private string NodeClass(FlowLayoutNode n)
+ {
+ var sb = new StringBuilder("fg-node");
+ if (n.PredictedOnly) sb.Append(" predicted");
+ if (n.Ghost) sb.Append(" ghost");
+ if (n.Predicted?.InputDependent == true) sb.Append(" input-dependent");
+ if (n.Step != null && SelectedEventId == n.Step.EventId) sb.Append(" selected");
+ switch (n.Step?.Outcome)
+ {
+ case FlowStepOutcome.Failed: sb.Append(" failed"); break;
+ case FlowStepOutcome.TimedOut: sb.Append(" timed-out"); break;
+ case FlowStepOutcome.NotTraced: sb.Append(" not-traced"); break;
+ }
+
+ return sb.ToString();
+ }
+
+ private static string EdgeClass(FlowLayoutEdge e)
+ {
+ var sb = new StringBuilder("fg-edge");
+ if (e.CrossesBoundary) sb.Append(" boundary");
+ if (e.External) sb.Append(" external");
+ if (e.PredictedOnly) sb.Append(" predicted");
+ return sb.ToString();
+ }
+
+ private static string MarkerFor(FlowLayoutEdge e)
+ => e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)";
+
+ private static string Eyebrow(FlowLayoutNode n) => n.Kind switch
+ {
+ FlowStepKind.InboundRequest => "REQUEST",
+ FlowStepKind.RouteCall => "ROUTE CALL",
+ FlowStepKind.ScriptRun => "SCRIPT RUN",
+ FlowStepKind.AttributeWrite => "WRITE",
+ FlowStepKind.AttributeWait => "WAIT",
+ FlowStepKind.Response => "RESPONSE",
+ FlowStepKind.External => "EXTERNAL",
+ _ => "STEP",
+ };
+
+ private static string Duration(FlowLayoutNode n)
+ => n.Step?.DurationMs is int ms ? ms.ToString(CultureInfo.InvariantCulture) + " ms" : (n.PredictedOnly ? "predicted" : string.Empty);
+
+ /// Clips a label to what fits the node width, keeping the full text in a title.
+ public static string Clip(string? text, int max)
+ {
+ if (string.IsNullOrEmpty(text)) return string.Empty;
+ return text.Length <= max ? text : text[..(max - 1)] + "…";
+ }
+
+ ///
+ /// Emits an SVG <text> element as pre-encoded markup. Razor reserves the
+ /// <text> tag for its own markup-mode switch and refuses attributes on it,
+ /// so the SVG element of the same name cannot be written literally inside a code block.
+ ///
+ public static MarkupString SvgText(string x, string y, string cssClass, string content, string? anchor = null, string? title = null, string? dataTest = null)
+ {
+ var sb = new StringBuilder("');
+ if (title != null) sb.Append("").Append(System.Net.WebUtility.HtmlEncode(title)).Append("");
+ sb.Append(System.Net.WebUtility.HtmlEncode(content)).Append("");
+ return new MarkupString(sb.ToString());
+ }
+
+ private Task SelectAsync(FlowLayoutNode n)
+ => n.Step != null && OnSelect.HasDelegate ? OnSelect.InvokeAsync(n.Step) : Task.CompletedTask;
+
+ private Task OnKeyAsync(KeyboardEventArgs e, FlowLayoutNode n)
+ => e.Key is "Enter" or " " or "Spacebar" ? SelectAsync(n) : Task.CompletedTask;
+
+ ///
+ /// Pure layout: assigns lanes and columns, resolves same-lane collisions by bumping to the
+ /// next free column, computes coordinates and orthogonal connector paths.
+ ///
+ /// The real graph, or null.
+ /// The prediction, or null.
+ public static FlowLayout ComputeLayout(FlowGraph? graph, PredictedFlow? predicted)
+ {
+ var hasReal = graph is { Steps.Count: > 0 };
+ var hasPredicted = predicted is { Nodes.Count: > 0 };
+
+ var nodes = new List();
+ var edges = new List();
+ var sublabels = new string[LaneCount];
+
+ // Node keys in insertion order + adjacency for the topological pass.
+ 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 stepByKey = new Dictionary(StringComparer.Ordinal);
+ var predByKey = new Dictionary(StringComparer.Ordinal);
+ var ghostKeys = new HashSet(StringComparer.Ordinal);
+
+ if (hasReal)
+ {
+ foreach (var s in graph!.Steps)
+ {
+ var k = "e:" + s.EventId.ToString("N");
+ keys.Add(k);
+ lane[k] = s.Lane;
+ stepByKey[k] = s;
+ if (s.Lane == FlowLane.Site && string.IsNullOrEmpty(sublabels[(int)FlowLane.Site]))
+ 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;
+ }
+
+ 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));
+ }
+
+ if (hasPredicted)
+ {
+ // Overlay: match predicted nodes to real steps by kind, in order; the leftovers are ghosts.
+ var unmatched = predicted!.Nodes.ToList();
+ foreach (var s in graph.Steps)
+ {
+ var m = unmatched.FirstOrDefault(p => p.Kind == s.Kind);
+ if (m != null)
+ {
+ unmatched.Remove(m);
+ predByKey["e:" + s.EventId.ToString("N")] = m;
+ }
+ }
+
+ foreach (var p in unmatched)
+ {
+ var k = "p:" + p.Id;
+ keys.Add(k);
+ lane[k] = p.Lane;
+ predByKey[k] = p;
+ ghostKeys.Add(k);
+ }
+ }
+ }
+ else if (hasPredicted)
+ {
+ foreach (var p in predicted!.Nodes)
+ {
+ var k = "p:" + p.Id;
+ keys.Add(k);
+ lane[k] = p.Lane;
+ predByKey[k] = p;
+ }
+
+ foreach (var e in predicted.Edges)
+ {
+ 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));
+ }
+ }
+
+ foreach (var (from, to, _, _, _) in rawEdges)
+ {
+ if (!preds.TryGetValue(to, out var list))
+ preds[to] = list = [];
+ list.Add(from);
+ }
+
+ // Topological column assignment in insertion order (the builders already emit a
+ // time/dependency-consistent order, so a single pass converges), with same-lane
+ // collision bumping so two independent siblings never overlap.
+ var column = new Dictionary(StringComparer.Ordinal);
+ var occupied = new HashSet<(FlowLane, int)>();
+ foreach (var k in keys)
+ {
+ if (ghostKeys.Contains(k))
+ continue;
+
+ var col = 0;
+ if (preds.TryGetValue(k, out var ps))
+ {
+ foreach (var p in ps)
+ {
+ if (column.TryGetValue(p, out var pc))
+ col = Math.Max(col, pc + 1);
+ }
+ }
+
+ while (!occupied.Add((lane[k], col)))
+ col++;
+ column[k] = col;
+ }
+
+ // Ghosts go after everything real, one column each in their own lane.
+ var maxCol = column.Count == 0 ? -1 : column.Values.Max();
+ foreach (var k in keys.Where(ghostKeys.Contains))
+ {
+ var col = maxCol + 1;
+ while (!occupied.Add((lane[k], col)))
+ col++;
+ column[k] = col;
+ maxCol = Math.Max(maxCol, col);
+ }
+
+ foreach (var k in keys)
+ {
+ var col = column[k];
+ var x = ColumnOrigin + col * ColumnPitch;
+ var y = LanePad + (int)lane[k] * LaneHeight;
+ stepByKey.TryGetValue(k, out var step);
+ predByKey.TryGetValue(k, out var pn);
+ nodes.Add(new FlowLayoutNode(k, lane[k], col, x, y, step, pn, ghostKeys.Contains(k)));
+ }
+
+ var byKey = nodes.ToDictionary(n => n.Key, StringComparer.Ordinal);
+ foreach (var (from, to, crosses, external, predictedOnly) in rawEdges)
+ {
+ var a = byKey[from];
+ var b = byKey[to];
+ edges.Add(new FlowLayoutEdge(from, to, OrthogonalPath(a, b), crosses, external, predictedOnly));
+ }
+
+ var width = ColumnOrigin + (maxCol + 1) * ColumnPitch + RightPad;
+ if (maxCol < 0) width = ColumnOrigin + ColumnPitch + RightPad;
+ var height = LaneCount * LaneHeight + LanePad;
+
+ return new FlowLayout(nodes, edges, width, height, sublabels.Select(s => s ?? string.Empty).ToArray());
+ }
+
+ private static string JoinLabel(string? a, string? b)
+ => string.IsNullOrEmpty(a) ? b ?? string.Empty : string.IsNullOrEmpty(b) ? a : $"{a} · {b}";
+
+ ///
+ /// Orthogonal connector from the right-middle of to the left-middle
+ /// of : straight when in the same lane, otherwise right-down/up-right
+ /// through the inter-column gap. A backwards edge (target left of source) loops under.
+ ///
+ public static string OrthogonalPath(FlowLayoutNode a, FlowLayoutNode b)
+ {
+ var x1 = a.X + NodeWidth;
+ var y1 = a.Y + NodeHeight / 2;
+ var x2 = b.X;
+ var y2 = b.Y + NodeHeight / 2;
+
+ if (Math.Abs(y1 - y2) < 0.01)
+ return $"M {F(x1)} {F(y1)} H {F(x2)}";
+
+ if (x2 >= x1)
+ {
+ var mid = x1 + (x2 - x1) / 2;
+ return $"M {F(x1)} {F(y1)} H {F(mid)} V {F(y2)} H {F(x2)}";
+ }
+
+ // Backwards: drop out of the source, run left, and come in from the target's left.
+ var drop = Math.Max(a.Y, b.Y) + NodeHeight + 8;
+ return $"M {F(x1)} {F(y1)} H {F(x1 + 8)} V {F(drop)} H {F(x2 - 8)} V {F(y2)} H {F(x2)}";
+ }
+}
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
new file mode 100644
index 00000000..2c6a5598
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css
@@ -0,0 +1,65 @@
+/* Flow Debugger swimlane — mirrors the design mockup's values through the kit's
+ CSS variables (theme.css light defaults; site.css dark overrides) so dark mode
+ follows automatically. Border #d2d2cb = --rule-strong, selected 2px #2f5fd0 on
+ #eef2fc = --accent on --active-bg. */
+
+.fg-wrap {
+ overflow: auto;
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ background: var(--bs-body-bg, #fff);
+}
+
+.fg-svg {
+ display: block;
+ font-family: var(--bs-body-font-family, system-ui, sans-serif);
+}
+
+/* Lanes */
+.fg-lane-bg { fill: transparent; }
+.fg-lane-bg.alt { fill: var(--rule); fill-opacity: 0.25; }
+.fg-lane-rule { stroke: var(--rule); stroke-width: 1; }
+.fg-lane-label { fill: var(--ink-soft); font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
+.fg-lane-sublabel{ fill: var(--ink-faint); font-size: 0.65rem; font-family: var(--mono); }
+.fg-empty { fill: var(--ink-faint); font-size: 0.85rem; font-style: italic; }
+
+/* Nodes */
+.fg-node { cursor: pointer; outline: none; }
+.fg-node .fg-node-box {
+ fill: var(--bs-body-bg, #fff);
+ stroke: var(--rule-strong);
+ stroke-width: 1;
+}
+.fg-node:hover .fg-node-box,
+.fg-node:focus-visible .fg-node-box { stroke: var(--accent); }
+.fg-node.selected .fg-node-box {
+ stroke: var(--accent);
+ stroke-width: 2;
+ fill: var(--active-bg);
+}
+.fg-node.predicted .fg-node-box { stroke-dasharray: 4 3; }
+.fg-node.input-dependent .fg-node-box { stroke-dasharray: 2 3; }
+.fg-node.ghost { opacity: 0.45; }
+.fg-node.ghost .fg-node-box { fill: var(--rule); fill-opacity: 0.35; }
+.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-eyebrow { fill: var(--ink-faint); font-size: 0.6rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
+.fg-duration { fill: var(--ink-soft); font-size: 0.6rem; font-family: var(--mono); }
+.fg-title { fill: var(--ink); font-size: 0.8rem; font-weight: 600; }
+.fg-subtitle { fill: var(--ink-soft); font-size: 0.68rem; }
+.fg-node.failed .fg-eyebrow,
+.fg-node.timed-out .fg-eyebrow { fill: var(--bs-danger, #c0392b); }
+
+/* Connectors */
+.fg-edge {
+ fill: none;
+ stroke: var(--ink-faint);
+ stroke-width: 1.5;
+}
+.fg-edge.boundary { stroke: var(--accent); }
+.fg-edge.external { stroke-dasharray: 5 4; }
+.fg-edge.predicted { stroke-opacity: 0.5; stroke-dasharray: 3 3; }
+.fg-arrowhead { fill: var(--ink-faint); }
+.fg-arrowhead.accent { fill: var(--accent); }
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs
new file mode 100644
index 00000000..73dc51c3
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs
@@ -0,0 +1,219 @@
+using System.Globalization;
+using Bunit;
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
+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;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
+
+///
+/// Tests for (Flow Debugger design 2026-09-05 §4): the pure
+/// swimlane layout over a Delmia-shaped real graph, selection/click behaviour, the empty
+/// state, prediction-only and ghost overlay rendering, and culture-invariant coordinates.
+///
+public class FlowGraphViewTests : BunitContext
+{
+ private static readonly Guid Root = Guid.NewGuid();
+ private static readonly Guid Child = Guid.NewGuid();
+ private static readonly DateTime T0 = new(2026, 9, 5, 14, 0, 0, DateTimeKind.Utc);
+
+ /// Inbound → route call → site run → two writes → wait (+ synthesized response) — the Delmia shape.
+ private static FlowGraph DelmiaGraph()
+ {
+ var events = new List
+ {
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
+ occurredAtUtc: T0, target: "POST /api/DelmiaRecipeDownload", executionId: Root, durationMs: 900,
+ requestSummary: "{}", responseSummary: """{"Result":true}"""),
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(5), target: "DelmiaSim01.ProcessRecipeDownload", executionId: Root, durationMs: 850,
+ extra: new ScriptTraceExtra { ChildExecutionId = Child }.ToJson()),
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(20), target: "ProcessRecipeDownload", executionId: Child, parentExecutionId: Root,
+ sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", durationMs: 800),
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(30), target: "DelmiaSim01.RecipeProcessedFlag", executionId: Child, parentExecutionId: Root,
+ sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", durationMs: 2),
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(40), target: "DelmiaSim01.RecipeDownloadFlag", executionId: Child, parentExecutionId: Root,
+ sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", durationMs: 3),
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWait, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(45), target: "DelmiaSim01.RecipeProcessedFlag", executionId: Child, parentExecutionId: Root,
+ sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", durationMs: 700,
+ extra: new ScriptTraceExtra { WaitAttribute = "RecipeProcessedFlag", WaitOutcome = "satisfied" }.ToJson()),
+ };
+ return FlowGraphBuilder.Build(events);
+ }
+
+ [Fact]
+ public void Layout_DelmiaGraph_AssignsExpectedLanesAndColumns()
+ {
+ var graph = DelmiaGraph();
+
+ var layout = FlowGraphView.ComputeLayout(graph, null);
+
+ var shape = layout.Nodes.Select(n => (n.Kind, n.Lane, n.Column)).ToList();
+ Assert.Equal(
+ [
+ (FlowStepKind.InboundRequest, FlowLane.Central, 0),
+ (FlowStepKind.RouteCall, FlowLane.Central, 1),
+ (FlowStepKind.ScriptRun, FlowLane.Site, 2),
+ (FlowStepKind.AttributeWrite, FlowLane.Tags, 3),
+ (FlowStepKind.AttributeWrite, FlowLane.Tags, 4),
+ (FlowStepKind.AttributeWait, FlowLane.Site, 5),
+ (FlowStepKind.Response, FlowLane.Central, 6),
+ ], shape);
+
+ // Coordinates follow the fixed grid.
+ var run = layout.Nodes[2];
+ Assert.Equal(FlowGraphView.ColumnOrigin + 2 * FlowGraphView.ColumnPitch, run.X);
+ Assert.Equal(FlowGraphView.LanePad + FlowGraphView.LaneHeight, run.Y);
+ Assert.Equal("site-a · DelmiaSim01", layout.LaneSublabels[(int)FlowLane.Site]);
+
+ Assert.Contains(layout.Edges, e => e.CrossesBoundary);
+ Assert.Contains(layout.Edges, e => e.External);
+ Assert.All(layout.Edges, e => Assert.StartsWith("M ", e.Path));
+ Assert.Equal(FlowGraphView.ColumnOrigin + 7 * FlowGraphView.ColumnPitch + FlowGraphView.RightPad, layout.Width);
+ }
+
+ [Fact]
+ public void Layout_IndependentSiblingsInOneLane_NeverShareAColumn()
+ {
+ var a = Guid.NewGuid();
+ var b = Guid.NewGuid();
+ var root = Guid.NewGuid();
+ var events = new List
+ {
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
+ occurredAtUtc: T0, target: "POST /api/M", executionId: root),
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(10), target: "A", executionId: a, parentExecutionId: root, sourceSiteId: "s"),
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
+ occurredAtUtc: T0.AddMilliseconds(10), target: "B", executionId: b, parentExecutionId: root, sourceSiteId: "s"),
+ };
+
+ var layout = FlowGraphView.ComputeLayout(FlowGraphBuilder.Build(events), null);
+
+ var siteCols = layout.Nodes.Where(n => n.Lane == FlowLane.Site).Select(n => n.Column).ToList();
+ Assert.Equal(2, siteCols.Count);
+ Assert.Equal(siteCols.Distinct().Count(), siteCols.Count);
+ }
+
+ [Fact]
+ public void Render_SelectedNode_CarriesSelectedClass()
+ {
+ var graph = DelmiaGraph();
+ var selected = graph.Steps[1];
+
+ var cut = Render(p => p
+ .Add(c => c.Graph, graph)
+ .Add(c => c.SelectedEventId, selected.EventId));
+
+ var nodes = cut.FindAll("g[data-test='flow-node']");
+ Assert.Equal(7, nodes.Count);
+ var selectedNodes = nodes.Where(n => n.ClassList.Contains("selected")).ToList();
+ var only = Assert.Single(selectedNodes);
+ Assert.Equal("e:" + selected.EventId.ToString("N"), only.GetAttribute("data-key"));
+ Assert.Equal("button", only.GetAttribute("role"));
+ Assert.Equal("0", only.GetAttribute("tabindex"));
+ }
+
+ [Fact]
+ public void Render_ClickingANode_RaisesOnSelectWithTheStep()
+ {
+ var graph = DelmiaGraph();
+ FlowStep? picked = null;
+
+ var cut = Render(p => p
+ .Add(c => c.Graph, graph)
+ .Add(c => c.OnSelect, s => picked = s));
+
+ cut.FindAll("g[data-test='flow-node']")[2].Click();
+
+ Assert.NotNull(picked);
+ Assert.Equal(FlowStepKind.ScriptRun, picked!.Kind);
+ }
+
+ [Fact]
+ public void Render_EmptyGraph_ShowsLanesAndNoStepsNote()
+ {
+ var cut = Render();
+
+ Assert.Equal(3, 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);
+ }
+
+ [Fact]
+ public void Render_PredictionOnly_DrawsDashedPredictedNodesAndEdges()
+ {
+ var predicted = FlowShapePredictor.PredictInboundMethod("M",
+ """return await Route.To(code).Call("S", new { });""", (_, _) => "await Attributes.SetAsync(\"X\", 1);");
+
+ var cut = Render(p => p.Add(c => c.Predicted, predicted));
+
+ var nodes = cut.FindAll("g[data-test='flow-node']");
+ Assert.Equal(predicted.Nodes.Count, nodes.Count);
+ Assert.All(nodes, n => Assert.Contains("predicted", n.ClassList));
+ Assert.Contains(nodes, n => n.ClassList.Contains("input-dependent"));
+ Assert.All(cut.FindAll("path[data-test='flow-edge']"), e => Assert.Contains("predicted", e.ClassList));
+ Assert.DoesNotContain(nodes, n => n.ClassList.Contains("ghost"));
+ }
+
+ [Fact]
+ public void Render_GraphWithPrediction_GreysPredictedNodesTheRunNeverProduced()
+ {
+ var graph = DelmiaGraph();
+ // Prediction with one extra wait the run did not perform.
+ var predicted = new PredictedFlow(
+ [
+ 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.ScriptRun, FlowLane.Site, "ProcessRecipeDownload", false, null),
+ new("n4", FlowStepKind.AttributeWait, FlowLane.Site, "DelmiaSim01.RecipeProcessedFlag", false, null),
+ new("n5", FlowStepKind.AttributeWait, FlowLane.Site, "DelmiaSim01.NeverWaited", false, null),
+ new("n6", FlowStepKind.Response, FlowLane.Central, "Response", false, null),
+ ], []);
+
+ var cut = Render(p => p.Add(c => c.Graph, graph).Add(c => c.Predicted, predicted));
+
+ var nodes = cut.FindAll("g[data-test='flow-node']");
+ Assert.Equal(8, nodes.Count); // 7 real + 1 ghost
+ var ghost = Assert.Single(nodes, n => n.ClassList.Contains("ghost"));
+ Assert.Equal("p:n5", ghost.GetAttribute("data-key"));
+ Assert.Equal("Site", ghost.GetAttribute("data-lane"));
+ // The ghost sits after every real column.
+ var realMax = nodes.Where(n => !n.ClassList.Contains("ghost")).Max(n => int.Parse(n.GetAttribute("data-column")!));
+ Assert.True(int.Parse(ghost.GetAttribute("data-column")!) > realMax);
+ }
+
+ [Fact]
+ public void Render_UnderGermanCulture_CoordinatesStayInvariant()
+ {
+ var previous = CultureInfo.CurrentCulture;
+ var previousUi = CultureInfo.CurrentUICulture;
+ CultureInfo.CurrentCulture = new CultureInfo("de-DE");
+ CultureInfo.CurrentUICulture = new CultureInfo("de-DE");
+ try
+ {
+ 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"));
+ Assert.DoesNotContain(",", svg.GetAttribute("viewBox"));
+ foreach (var path in cut.FindAll("path[data-test='flow-edge']"))
+ Assert.DoesNotContain(",", path.GetAttribute("d"));
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = previous;
+ CultureInfo.CurrentUICulture = previousUi;
+ }
+ }
+}