feat(central-ui): FlowGraphView SVG swimlane renderer

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 <g> 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 <text> tag, so SVG text elements are emitted through a small
HTML-encoding MarkupString helper.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-06 00:25:54 -04:00
parent f9b8a2cf8f
commit ba15a38327
4 changed files with 741 additions and 0 deletions
@@ -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). *@
<div class="fg-wrap" data-test="flow-graph">
<svg width="@RenderedWidth" height="@RenderedHeight" viewBox="@ViewBox"
role="img" aria-label="@AriaSummary" class="fg-svg">
<defs>
<marker id="fg-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" class="fg-arrowhead" />
</marker>
<marker id="fg-arrow-accent" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" class="fg-arrowhead accent" />
</marker>
</defs>
@* Lanes *@
@for (var lane = 0; lane < 3; lane++)
{
var i = lane;
<g class="fg-lane" data-lane="@LaneName(i)">
<rect x="0" y="@LaneY(i)" width="@F(_layout.Width)" height="@F(LaneHeight)" class="fg-lane-bg @(i % 2 == 1 ? "alt" : "")" />
<line x1="0" y1="@LaneY(i)" x2="@F(_layout.Width)" y2="@LaneY(i)" class="fg-lane-rule" />
@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])
}
</g>
}
@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)
{
<path d="@e.Path" class="@EdgeClass(e)" marker-end="@MarkerFor(e)" data-test="flow-edge" data-from="@e.From" data-to="@e.To" />
}
@* Nodes *@
@foreach (var n in _layout.Nodes)
{
var node = n;
<g class="@NodeClass(node)" transform="translate(@F(node.X) @F(node.Y))"
role="button" tabindex="0"
aria-label="@(Eyebrow(node) + ": " + node.Title)"
data-test="flow-node" data-key="@node.Key" data-kind="@node.Kind" data-lane="@node.Lane" data-column="@node.Column"
@onclick="() => SelectAsync(node)" @onkeydown="e => OnKeyAsync(e, node)">
<title>@node.Title@(string.IsNullOrEmpty(node.Subtitle) ? "" : " — " + node.Subtitle)</title>
<rect x="0" y="0" width="@F(NodeWidth)" height="@F(NodeHeight)" rx="4" ry="4" class="fg-node-box" />
@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))
</g>
}
</svg>
</div>
@@ -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;
/// <summary>One laid-out node of the swimlane: a real step, a predicted node, or both (matched).</summary>
/// <param name="Key">Stable render key (<c>e:{eventId}</c> for real steps, <c>p:{id}</c> for predicted-only nodes).</param>
/// <param name="Lane">Swimlane.</param>
/// <param name="Column">Zero-based column from the topological pass.</param>
/// <param name="X">Left edge in SVG units.</param>
/// <param name="Y">Top edge in SVG units.</param>
/// <param name="Step">The real step, null for a predicted-only node.</param>
/// <param name="Predicted">The predicted node, null for a real step with no prediction match.</param>
/// <param name="Ghost">True for a predicted node the real graph never produced (greyed).</param>
public sealed record FlowLayoutNode(
string Key, FlowLane Lane, int Column, double X, double Y, FlowStep? Step, PredictedNode? Predicted, bool Ghost)
{
/// <summary>True when this node comes from a prediction only (no real step yet).</summary>
public bool PredictedOnly => Step == null;
/// <summary>Display kind.</summary>
public FlowStepKind Kind => Step?.Kind ?? Predicted!.Kind;
/// <summary>Display title.</summary>
public string Title => Step?.Title ?? Predicted!.Title;
/// <summary>Display subtitle (real: the step's; predicted: the note).</summary>
public string? Subtitle => Step?.Subtitle ?? Predicted?.Note;
}
/// <summary>One laid-out connector.</summary>
/// <param name="From">Source node key.</param>
/// <param name="To">Target node key.</param>
/// <param name="Path">SVG path data (orthogonal, culture-invariant).</param>
/// <param name="CrossesBoundary">Accent colour — a central↔site hop.</param>
/// <param name="External">Dashed — an external actor (the tag source responding).</param>
/// <param name="PredictedOnly">Light — an edge of the prediction, not of a run.</param>
public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly);
/// <summary>A computed swimlane layout.</summary>
/// <param name="Nodes">Nodes with coordinates.</param>
/// <param name="Edges">Connectors with path data.</param>
/// <param name="Width">Canvas width in SVG units.</param>
/// <param name="Height">Canvas height in SVG units.</param>
/// <param name="LaneSublabels">Per-lane sublabel (index = <see cref="FlowLane"/>), empty when unknown.</param>
public sealed record FlowLayout(
IReadOnlyList<FlowLayoutNode> Nodes,
IReadOnlyList<FlowLayoutEdge> Edges,
double Width,
double Height,
IReadOnlyList<string> LaneSublabels)
{
/// <summary>True when there is nothing to draw.</summary>
public bool IsEmpty => Nodes.Count == 0;
}
/// <summary>
/// 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 <see cref="FlowGraph"/>, a <see cref="PredictedFlow"/>, or both —
/// in which case predicted nodes the run never produced are greyed. The layout is a pure
/// function (<see cref="ComputeLayout"/>) so it is unit-tested without rendering; every
/// coordinate string is <see cref="CultureInfo.InvariantCulture"/>-formatted.
/// </summary>
public partial class FlowGraphView : ComponentBase
{
/// <summary>Lane height in SVG units.</summary>
public const double LaneHeight = 110;
/// <summary>Node width.</summary>
public const double NodeWidth = 150;
/// <summary>Node height.</summary>
public const double NodeHeight = 78;
/// <summary>Left edge of column 0 (the lane label gutter sits to its left).</summary>
public const double ColumnOrigin = 130;
/// <summary>Column pitch.</summary>
public const double ColumnPitch = 170;
/// <summary>Top padding inside a lane.</summary>
public const double LanePad = 16;
/// <summary>Right-hand canvas padding.</summary>
public const double RightPad = 20;
private const int LaneCount = 3;
/// <summary>The real graph (from a run or a replay); null before a run.</summary>
[Parameter] public FlowGraph? Graph { get; set; }
/// <summary>The predicted flow; drawn alone before a run, overlaid (ghosts) after one.</summary>
[Parameter] public PredictedFlow? Predicted { get; set; }
/// <summary>The selected real step's event id.</summary>
[Parameter] public Guid? SelectedEventId { get; set; }
/// <summary>Raised when a real step is clicked or activated with Enter/Space.</summary>
[Parameter] public EventCallback<FlowStep> OnSelect { get; set; }
/// <summary>Zoom factor applied to the rendered size (the viewBox is fixed).</summary>
[Parameter] public double Zoom { get; set; } = 1.0;
private FlowLayout _layout = ComputeLayout(null, null);
/// <inheritdoc/>
protected override void OnParametersSet()
{
_layout = ComputeLayout(Graph, Predicted);
}
/// <summary>Formats a coordinate culture-invariantly.</summary>
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);
/// <summary>Clips a label to what fits the node width, keeping the full text in a <c>title</c>.</summary>
public static string Clip(string? text, int max)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
return text.Length <= max ? text : text[..(max - 1)] + "…";
}
/// <summary>
/// Emits an SVG <c>&lt;text&gt;</c> element as pre-encoded markup. Razor reserves the
/// <c>&lt;text&gt;</c> 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.
/// </summary>
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("<text x=\"").Append(x).Append("\" y=\"").Append(y).Append("\" class=\"").Append(cssClass).Append('"');
if (anchor != null) sb.Append(" text-anchor=\"").Append(anchor).Append('"');
if (dataTest != null) sb.Append(" data-test=\"").Append(dataTest).Append('"');
sb.Append('>');
if (title != null) sb.Append("<title>").Append(System.Net.WebUtility.HtmlEncode(title)).Append("</title>");
sb.Append(System.Net.WebUtility.HtmlEncode(content)).Append("</text>");
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;
/// <summary>
/// Pure layout: assigns lanes and columns, resolves same-lane collisions by bumping to the
/// next free column, computes coordinates and orthogonal connector paths.
/// </summary>
/// <param name="graph">The real graph, or null.</param>
/// <param name="predicted">The prediction, or null.</param>
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<FlowLayoutNode>();
var edges = new List<FlowLayoutEdge>();
var sublabels = new string[LaneCount];
// Node keys in insertion order + adjacency for the topological pass.
var keys = new List<string>();
var lane = new Dictionary<string, FlowLane>(StringComparer.Ordinal);
var preds = new Dictionary<string, List<string>>(StringComparer.Ordinal);
var rawEdges = new List<(string From, string To, bool Crosses, bool External, bool PredictedOnly)>();
var stepByKey = new Dictionary<string, FlowStep>(StringComparer.Ordinal);
var predByKey = new Dictionary<string, PredictedNode>(StringComparer.Ordinal);
var ghostKeys = new HashSet<string>(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<string, int>(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}";
/// <summary>
/// Orthogonal connector from the right-middle of <paramref name="a"/> to the left-middle
/// of <paramref name="b"/>: 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.
/// </summary>
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)}";
}
}
@@ -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); }
@@ -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;
/// <summary>
/// Tests for <see cref="FlowGraphView"/> (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.
/// </summary>
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);
/// <summary>Inbound → route call → site run → two writes → wait (+ synthesized response) — the Delmia shape.</summary>
private static FlowGraph DelmiaGraph()
{
var events = new List<AuditEvent>
{
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<AuditEvent>
{
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<FlowGraphView>(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<FlowGraphView>(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<FlowGraphView>();
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<FlowGraphView>(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<FlowGraphView>(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<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"));
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;
}
}
}