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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user