Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs
T
Joseph Doherty ba15a38327 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
2026-09-06 00:25:54 -04:00

220 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}
}