From ba15a38327b2ae4cc3ffecc998c290c08fb5cc3d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 6 Sep 2026 00:25:54 -0400 Subject: [PATCH] feat(central-ui): FlowGraphView SVG swimlane renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependency-free inline SVG: three fixed 110px lanes (Central / Site / Tags, sublabelled from the steps' site/instance), columns from a topological pass over the graph edges with same-lane collision bumping so independent siblings never overlap, 150x78 nodes on a 130+col*170 grid, orthogonal connectors with arrow markers (accent for boundary crossings, dashed for external actors, light for predicted edges). Renders a real FlowGraph, a PredictedFlow (dashed nodes, input-dependent finer-dashed), or both — predicted nodes matched to real steps by kind in order, the leftovers appended greyed as ghosts. Selected node = accent 2px on --active-bg; failed/timed-out/not-traced outcomes styled; each node a role=button tabindex=0 with click + Enter/Space selection; svg role=img with a summary aria-label. Layout is a pure static ComputeLayout, every coordinate InvariantCulture. Razor reserves the tag, so SVG text elements are emitted through a small HTML-encoding MarkupString helper. Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP --- .../Components/Shared/FlowGraphView.razor | 65 +++ .../Components/Shared/FlowGraphView.razor.cs | 392 ++++++++++++++++++ .../Components/Shared/FlowGraphView.razor.css | 65 +++ .../Shared/FlowGraphViewTests.cs | 219 ++++++++++ 4 files changed, 741 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs 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). *@ + +
+ + + + + + + + + + + @* Lanes *@ + @for (var lane = 0; lane < 3; lane++) + { + var i = lane; + + + + @SvgText("12", LaneLabelY(i), "fg-lane-label", LaneName(i)) + @if (!string.IsNullOrEmpty(_layout.LaneSublabels[i])) + { + @SvgText("12", F(i * LaneHeight + 40), "fg-lane-sublabel", Clip(_layout.LaneSublabels[i], 16), title: _layout.LaneSublabels[i]) + } + + } + + @if (_layout.IsEmpty) + { + @SvgText(F(ColumnOrigin), F(LanePad + 44), "fg-empty", "No steps", dataTest: "flow-graph-empty") + } + + @* Connectors (under the nodes) *@ + @foreach (var e in _layout.Edges) + { + + } + + @* Nodes *@ + @foreach (var n in _layout.Nodes) + { + var node = n; + + @node.Title@(string.IsNullOrEmpty(node.Subtitle) ? "" : " — " + node.Subtitle) + + @SvgText("10", "16", "fg-eyebrow", Eyebrow(node)) + @SvgText(F(NodeWidth - 10), "16", "fg-duration", Duration(node), anchor: "end") + @SvgText("10", "40", "fg-title", Clip(node.Title, 20)) + @SvgText("10", "60", "fg-subtitle", Clip(node.Subtitle, 24)) + + } + +
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; + } + } +}