Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs
T

719 lines
38 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(4, 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);
Assert.Contains("External", 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 Overlay_TwoPredictedRouteCalls_RealSecondCallMatchesByTitle_FirstIsTheGhost()
{
// The run only ever made the second of two predicted route calls (a branch skipped the first).
// Matching by kind alone would pair the real "B.Y" step with the first predicted node ("A.X")
// and leave "B.Y" as the ghost — wrong: "A.X" is the one that never ran.
var step = new FlowStep(Guid.NewGuid(), null, null, FlowStepKind.RouteCall, FlowLane.Central, "B.Y", null,
T0, 5, FlowStepOutcome.Ok, null, null, null, null, null, null, "B.Y", null);
var graph = new FlowGraph([step], [], false, false);
var predicted = new PredictedFlow(
[
new("n1", FlowStepKind.RouteCall, FlowLane.Central, "A.X", false, null),
new("n2", FlowStepKind.RouteCall, FlowLane.Central, "B.Y", false, null),
], []);
var layout = FlowGraphView.ComputeLayout(graph, predicted);
var real = Assert.Single(layout.Nodes, n => n.Step != null);
Assert.Equal("B.Y", real.Predicted?.Title);
var ghost = Assert.Single(layout.Nodes, n => n.Ghost);
Assert.Equal("p:n1", ghost.Key);
Assert.Equal("A.X", ghost.Predicted?.Title);
}
[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");
// Four lanes: (4 × 110 + 16) × 0.7 = 456 × 0.7 = 319.2 — a comma here would be the German decimal separator leaking through.
Assert.Equal((4 * FlowGraphView.LaneHeight + FlowGraphView.LanePad) * 0.7, 319.2, 9);
Assert.Equal("319.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;
}
}
// ── Node text readability (2026-09-06): shared-prefix stripping, width-based clipping, tooltips ──
[Theory]
[InlineData(new[] { "DelmiaReceiver.RecipeDownloadFlag", "DelmiaReceiver.RecipeProcessedFlag" }, "DelmiaReceiver.")]
[InlineData(new[] { "A.B.C.X", "A.B.C.Y", "A.B.D.Z" }, "A.B.")]
[InlineData(new[] { "Same.Path", "Same.Path" }, "Same.")]
[InlineData(new[] { "DelmiaReceiver.RecipeDownloadFlag" }, null)]
[InlineData(new[] { "ProcessRecipeDownload", "DelmiaReceiverSimulator" }, null)]
[InlineData(new[] { "NoDotsHere", "NoDotsThere" }, null)]
[InlineData(new[] { "A.B", "A.B.C" }, "A.")]
public void CommonDottedPrefix_FactorsOnlyASharedDottedHeadThatLeavesEveryTail(string[] titles, string? expected)
{
Assert.Equal(expected, FlowGraphView.CommonDottedPrefix(titles));
}
[Theory]
[InlineData("DelmiaRecipeDownload", "Delmia\u200BRecipe\u200BDownload")]
[InlineData("DelmiaSim01.ProcessRecipeDownload", "Delmia\u200BSim01.\u200BProcess\u200BRecipe\u200BDownload")]
[InlineData("POST /api/X", "POST /\u200Bapi/\u200BX")]
[InlineData("lower", "lower")]
[InlineData("", "")]
[InlineData(null, "")]
public void WrapHints_MarksCamelCaseAndSeparatorBoundaries(string? input, string expected)
{
Assert.Equal(expected, FlowGraphView.WrapHints(input));
}
[Fact]
public void StripPrefix_RemovesTheHeadFromTheTitleAndFromEachSubtitleItem()
{
Assert.Equal("RecipeDownloadFlag", FlowGraphView.StripPrefix("DelmiaReceiver.RecipeDownloadFlag", "DelmiaReceiver."));
Assert.Equal("X = 1, Y = true", FlowGraphView.StripPrefix("DelmiaReceiver.X = 1, DelmiaReceiver.Y = true", "DelmiaReceiver."));
Assert.Equal("Other.Z", FlowGraphView.StripPrefix("Other.Z", "DelmiaReceiver."));
Assert.Null(FlowGraphView.StripPrefix(null, "DelmiaReceiver."));
Assert.Equal("kept", FlowGraphView.StripPrefix("kept", null));
}
[Fact]
public void Layout_TagsLane_FactorsTheSharedDottedHeadIntoTheGutter()
{
var layout = FlowGraphView.ComputeLayout(DelmiaGraph(), null);
Assert.Equal("DelmiaSim01.", layout.LanePrefixes[(int)FlowLane.Tags]);
Assert.Equal(string.Empty, layout.LanePrefixes[(int)FlowLane.Central]);
Assert.Equal(string.Empty, layout.LanePrefixes[(int)FlowLane.Site]);
var writes = layout.Nodes.Where(n => n.Kind == FlowStepKind.AttributeWrite).ToList();
Assert.Equal(["RecipeProcessedFlag", "RecipeDownloadFlag"], writes.Select(w => w.DisplayTitle));
Assert.All(writes, w => Assert.StartsWith("DelmiaSim01.", w.Title));
// Site-lane titles share no dotted head, so they render unchanged.
var wait = layout.Nodes.Single(n => n.Kind == FlowStepKind.AttributeWait);
Assert.Equal(wait.Title, wait.DisplayTitle);
}
[Fact]
public void Layout_PredictedOnlyNodes_NeverGetALanePrefix()
{
var predicted = FlowShapePredictor.PredictSiteScript("s", "Attributes.SetAsync(\"Flag\", true);");
var layout = FlowGraphView.ComputeLayout(null, predicted);
Assert.All(layout.Nodes, n => Assert.Null(n.LanePrefix));
Assert.All(layout.LanePrefixes, p => Assert.Equal(string.Empty, p));
}
[Fact]
public void Render_NodeBodyIsHtmlInAForeignObject_WithStrippedTitleAndFullTextTooltip()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, DelmiaGraph()));
var write = cut.FindAll("g[data-test='flow-node'][data-kind='AttributeWrite']")[0];
var body = write.QuerySelector("foreignObject [data-test='flow-node-body']")!;
var title = write.QuerySelector("[data-test='flow-node-title']")!.TextContent.Trim();
// Wrap hints (zero-width spaces) sit at the camel-case boundaries and nowhere else.
Assert.Equal("Recipe\u200BProcessed\u200BFlag", title);
Assert.Equal("RecipeProcessedFlag", title.Replace("\u200B", ""));
Assert.StartsWith("DelmiaSim01.RecipeProcessedFlag", body.GetAttribute("title"));
// The gutter shows the factored head once.
var prefix = cut.Find("[data-test='flow-lane-prefix']");
Assert.Equal("DelmiaSim01.*", prefix.TextContent.Replace(prefix.QuerySelector("title")?.TextContent ?? "", "").Trim());
// No character-count clipping in any node body: the browser ellipsizes by width instead.
Assert.All(cut.FindAll("[data-test='flow-node-body']"), b => Assert.DoesNotContain("…", b.TextContent));
// The gutter sublabel fits without clipping at the widened gutter.
Assert.Contains("site-a · DelmiaSim01", cut.Markup.Replace("&#183;", "·"));
}
// ── Outbound calls (plan 2026-09-06): External lane, status pills, async/spawn connectors ──
private static readonly Guid SiteRun = Guid.NewGuid();
private static readonly Guid Cascade = Guid.NewGuid();
private static AuditEvent Outbound(AuditChannel channel, AuditKind kind, AuditStatus status, int ms, Guid? correlation, string target,
string node = "node-a", string? error = null, int? durationMs = null)
=> ScadaBridgeAuditEventFactory.Create(channel, kind, status,
occurredAtUtc: T0.AddMilliseconds(ms), actor: "S", target: target, sourceNode: node, correlationId: correlation,
executionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", errorMessage: error, durationMs: durationMs);
private static AuditEvent WriteRow(int ms, string attribute) => ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(ms), target: "M1." + attribute, executionId: SiteRun, parentExecutionId: Root,
sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 1,
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("M1." + attribute, "1", true, null)] }.ToJson());
/// <summary>
/// Inbound → route → site run → write A → sync API call → write B → (spawned cascade run), with a
/// pending cached DB write and a parked notification branching off the run.
/// </summary>
private static FlowGraph OutboundGraph(bool fourthTarget = false)
{
var events = new List<AuditEvent>
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: T0, target: "POST /api/FlowOutboundDemo", executionId: Root, durationMs: 900, responseSummary: "{}"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(5), target: "M1.OutboundDemo", executionId: Root, durationMs: 850,
extra: new ScriptTraceExtra { ChildExecutionId = SiteRun }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(20), target: "OutboundDemo", executionId: SiteRun, parentExecutionId: Root,
sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 800),
WriteRow(30, "A"),
Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, 35, null, "Delmia.AckRecipe", durationMs: 212),
Outbound(AuditChannel.DbOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, 36, Guid.NewGuid(), "MesDb"),
WriteRow(40, "B"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(45), target: "OnBChanged", executionId: Cascade, parentExecutionId: SiteRun,
sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 10,
extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "M1.B", TriggerValue = "1" }.ToJson()),
};
var notificationId = Guid.NewGuid();
events.Add(Outbound(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted, 37, notificationId, "Planners"));
events.Add(Outbound(AuditChannel.Notification, AuditKind.NotifyDeliver, AuditStatus.Parked, 9002, notificationId, "Planners", node: "central-a", error: "retries exhausted"));
if (fourthTarget)
events.Add(Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, 38, null, "Other.Thing", durationMs: 5));
return FlowGraphBuilder.Build(events);
}
[Fact]
public void Layout_OutboundSteps_LandInTheExternalLane_WithATargetSublabel()
{
var layout = FlowGraphView.ComputeLayout(OutboundGraph(), null);
var external = layout.Nodes.Where(n => n.Lane == FlowLane.External).ToList();
Assert.Equal(3, external.Count);
Assert.Equal([FlowStepKind.ApiCall, FlowStepKind.DbCall, FlowStepKind.Notify], external.Select(n => n.Kind));
Assert.All(external, n => Assert.Equal(FlowGraphView.LanePad + 3 * FlowGraphView.LaneHeight, n.Y));
Assert.Equal("Delmia.AckRecipe · MesDb · Planners", layout.LaneSublabels[(int)FlowLane.External]);
Assert.Equal(4 * FlowGraphView.LaneHeight + FlowGraphView.LanePad, layout.Height);
// The edge flags ride through from the graph.
Assert.Equal(2, layout.Edges.Count(e => e.Async));
Assert.Single(layout.Edges, e => e.Spawn);
}
[Fact]
public void Layout_ExternalSublabel_ListsThreeTargetsThenCounts()
{
var layout = FlowGraphView.ComputeLayout(OutboundGraph(fourthTarget: true), null);
Assert.Equal("Delmia.AckRecipe · MesDb · Planners +1", layout.LaneSublabels[(int)FlowLane.External]);
}
[Fact]
public void Render_ApiCall_HasEyebrowAndOkPillWithSyncMode()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, OutboundGraph()));
Assert.Equal(4, cut.FindAll("g.fg-lane").Count);
var api = cut.Find("g[data-test='flow-node'][data-kind='ApiCall']");
Assert.Equal("External", api.GetAttribute("data-lane"));
Assert.Equal("API CALL", api.QuerySelector(".fg-eyebrow")!.TextContent.Trim());
Assert.Equal("212 ms", api.QuerySelector(".fg-duration")!.TextContent.Trim());
var pill = api.QuerySelector("[data-test='flow-node-pill']")!;
Assert.Equal("Ok", pill.TextContent.Trim());
Assert.Contains("ok", pill.ClassList);
Assert.Equal("· sync", api.QuerySelector("[data-test='flow-node-status'] .fg-mode")!.TextContent.Trim());
Assert.DoesNotContain("pending", api.ClassList);
Assert.DoesNotContain("parked", api.ClassList);
// Non-outbound nodes keep the plain subtitle line and get no pill.
var run = cut.Find("g[data-test='flow-node'][data-kind='ScriptRun']");
Assert.Null(run.QuerySelector("[data-test='flow-node-pill']"));
Assert.NotNull(run.QuerySelector("[data-test='flow-node-subtitle']"));
}
[Fact]
public void Render_PendingCachedCall_HasPendingClassAndPill()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, OutboundGraph()));
var db = cut.Find("g[data-test='flow-node'][data-kind='DbCall']");
Assert.Contains("pending", db.ClassList);
Assert.Equal("DATABASE", db.QuerySelector(".fg-eyebrow")!.TextContent.Trim());
var pill = db.QuerySelector("[data-test='flow-node-pill']")!;
Assert.Equal("Pending", pill.TextContent.Trim());
Assert.Contains("pending", pill.ClassList);
Assert.Equal("· cached", db.QuerySelector("[data-test='flow-node-status'] .fg-mode")!.TextContent.Trim());
Assert.Equal(string.Empty, db.QuerySelector(".fg-duration")!.TextContent.Trim());
}
[Fact]
public void Render_ParkedNotification_HasParkedClass_AndSubmitToTerminalDuration()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, OutboundGraph()));
var notify = cut.Find("g[data-test='flow-node'][data-kind='Notify']");
Assert.Contains("parked", notify.ClassList);
Assert.Equal("NOTIFY", notify.QuerySelector(".fg-eyebrow")!.TextContent.Trim());
var pill = notify.QuerySelector("[data-test='flow-node-pill']")!;
Assert.Equal("Parked", pill.TextContent.Trim());
Assert.Contains("parked", pill.ClassList);
Assert.Equal("· notify", notify.QuerySelector("[data-test='flow-node-status'] .fg-mode")!.TextContent.Trim());
// A folded lifecycle shows submit→terminal wall time with a leading "+" (8965 ms → "+9 s").
Assert.Equal("+9 s", notify.QuerySelector(".fg-duration")!.TextContent.Trim());
}
[Fact]
public void Render_AsyncEdgesAreDashedClass_SpawnEdgeKeepsAnArrowheadAndGetsABoltBadge()
{
var graph = OutboundGraph();
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, graph));
// Rig 2026-09-08: the bolt used to replace the arrowhead and read as a broken arrow on the upward
// Tags→Site hop. Every edge now ends in a real arrowhead; the bolt is a badge on the connector.
Assert.Empty(cut.FindAll("marker#fg-arrow-spawn"));
var edges = cut.FindAll("path[data-test='flow-edge']");
var async = edges.Where(e => e.ClassList.Contains("async")).ToList();
Assert.Equal(2, async.Count);
Assert.All(async, e => Assert.Equal("url(#fg-arrow)", e.GetAttribute("marker-end")));
Assert.All(async, e => Assert.DoesNotContain("external", e.ClassList));
var spawn = Assert.Single(edges, e => e.ClassList.Contains("spawn"));
Assert.Equal("url(#fg-arrow)", spawn.GetAttribute("marker-end"));
Assert.All(edges, e => Assert.DoesNotContain(",", e.GetAttribute("d")));
var badge = Assert.Single(cut.FindAll("g[data-test='flow-edge-spawn']"));
Assert.NotNull(badge.QuerySelector("path.fg-spawn-bolt"));
Assert.NotNull(badge.QuerySelector("circle.fg-spawn-badge-bg"));
// The badge sits on the riser of the edge it belongs to: between the two nodes horizontally and
// between the two lanes vertically, so it never overlaps either box.
var layout = FlowGraphView.ComputeLayout(graph, null);
var a = layout.Nodes.Single(n => n.Key == spawn.GetAttribute("data-from"));
var b = layout.Nodes.Single(n => n.Key == spawn.GetAttribute("data-to"));
var (x, y) = FlowGraphView.SpawnBadgePoint(a, b, isAsync: false);
Assert.Equal($"translate({FlowGraphView.F(x)} {FlowGraphView.F(y)})", badge.GetAttribute("transform"));
Assert.InRange(x, a.X + FlowGraphView.NodeWidth, b.X);
Assert.InRange(y, Math.Min(a.Y, b.Y) + FlowGraphView.NodeHeight / 2, Math.Max(a.Y, b.Y) + FlowGraphView.NodeHeight / 2);
}
[Fact]
public void SpawnBadgePoint_SitsOnTheVerticalRunOfEachConnectorShape()
{
var w = FlowGraphView.NodeWidth; var h = FlowGraphView.NodeHeight;
FlowLayoutNode N(string k, FlowLane lane, int col, double x, double y) => new(k, lane, col, x, y, null, null, false);
var tagsWrite = N("w", FlowLane.Tags, 0, 160, 16 + 2 * 110);
var siteRun = N("r", FlowLane.Site, 1, 330, 16 + 110);
// Upward Tags→Site hop (the rig shape): mid-gap x, halfway between the two lane centres.
var (x, y) = FlowGraphView.SpawnBadgePoint(tagsWrite, siteRun, isAsync: false);
Assert.Equal(160 + w + (330 - (160 + w)) / 2, x);
Assert.Equal(((16 + 220 + h / 2) + (16 + 110 + h / 2)) / 2, y);
Assert.Contains($"H {FlowGraphView.F(x)} V", FlowGraphView.OrthogonalPath(tagsWrite, siteRun));
// Same lane: the middle of the straight line.
var next = N("n", FlowLane.Tags, 1, 330, tagsWrite.Y);
Assert.Equal((160 + w + 330) / 2, FlowGraphView.SpawnBadgePoint(tagsWrite, next, false).X);
Assert.Equal(tagsWrite.Y + h / 2, FlowGraphView.SpawnBadgePoint(tagsWrite, next, false).Y);
// Backwards: on the loop under both nodes.
var back = N("b", FlowLane.Site, 0, 160, siteRun.Y);
Assert.Equal(Math.Max(tagsWrite.Y, back.Y) + h + 8, FlowGraphView.SpawnBadgePoint(next, back, false).Y);
// Async drop to a lower lane: on the first vertical run out of the source's bottom-centre.
var external = N("e", FlowLane.External, 1, 330, 16 + 3 * 110);
var (ax, ay) = FlowGraphView.SpawnBadgePoint(siteRun, external, isAsync: true);
Assert.Equal(siteRun.X + w / 2, ax);
Assert.InRange(ay, siteRun.Y + h, external.Y - FlowGraphView.LanePad / 2);
}
[Theory]
[InlineData(FlowStepKind.ApiCall, "Delivered · sync", "sync")]
[InlineData(FlowStepKind.ApiCall, "Failed · sync", "sync")]
[InlineData(FlowStepKind.ApiCall, "Delivered · cached", "cached")]
[InlineData(FlowStepKind.DbCall, "Submitted · cached", "cached")]
[InlineData(FlowStepKind.DbCall, "write · 3 rows", "write · 3 rows · sync")]
[InlineData(FlowStepKind.DbCall, "Delivered", "sync")]
[InlineData(FlowStepKind.DbCall, null, "sync")]
[InlineData(FlowStepKind.Notify, "Parked", "notify")]
[InlineData(FlowStepKind.Notify, null, "notify")]
public void OutboundMode_DropsTheStatusThePillCarries_AndNamesTheCallMode(FlowStepKind kind, string? subtitle, string expected)
{
Assert.Equal(expected, FlowGraphView.OutboundMode(kind, subtitle));
}
[Fact]
public void Render_PredictedOutboundNodes_AreGhostsWithoutPills()
{
var predicted = new PredictedFlow(
[
new("n1", FlowStepKind.ScriptRun, FlowLane.Site, "OutboundDemo", false, null),
new("n2", FlowStepKind.ApiCall, FlowLane.External, "Delmia.AckRecipe", false, "sync"),
new("n3", FlowStepKind.Notify, FlowLane.External, "Notify Planners", false, "async"),
], [new("n1", "n2", false), new("n1", "n3", false)]);
var cut = Render<FlowGraphView>(p => p.Add(c => c.Predicted, predicted));
var external = cut.FindAll("g[data-test='flow-node'][data-lane='External']");
Assert.Equal(2, external.Count);
Assert.All(external, n => Assert.Contains("predicted", n.ClassList));
Assert.All(external, n => Assert.Null(n.QuerySelector("[data-test='flow-node-pill']")));
Assert.Equal("API CALL", external[0].QuerySelector(".fg-eyebrow")!.TextContent.Trim());
}
[Fact]
public void PredictedAsyncEdge_RendersWithAsyncClass()
{
var flow = new PredictedFlow(
[
new("n1", FlowStepKind.ScriptRun, FlowLane.Site, "S", false, null),
new("n2", FlowStepKind.Notify, FlowLane.External, "Notify Planners", false, null),
],
[new("n1", "n2", false, Async: true)]);
var layout = FlowGraphView.ComputeLayout(null, flow);
var edge = Assert.Single(layout.Edges);
Assert.True(edge.Async);
}
// ── Step logs + exception subtitle (step-logs plan 2026-09-09, D8) ──
private static FlowLogLine Line(string level, string message = "m", int seq = 0)
=> new(Guid.NewGuid(), Root, T0.AddMilliseconds(seq), level, "script", message, null, null, null, seq);
/// <summary>A one-node Site-lane graph whose single step carries the given logs / error shape.</summary>
private static FlowGraph LogGraph(
FlowStepOutcome outcome = FlowStepOutcome.Ok,
string? errorMessage = null,
string? errorDetail = null,
IReadOnlyList<FlowLogLine>? logs = null)
{
var step = new FlowStep(Guid.NewGuid(), Root, null, FlowStepKind.ScriptRun, FlowLane.Site, "DoWork", "trigger: manual",
T0, 5, outcome, null, null, errorMessage, "site-a", "node-a", "M1", "DoWork", null, null, null, errorDetail, logs);
return new FlowGraph([step], [], false, false);
}
[Fact]
public void Render_StepWithNoLogLines_HasNoLogBadge()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph()));
Assert.Empty(cut.FindAll("[data-test='flow-node-logs']"));
// The footer still exists and holds the subtitle.
Assert.NotNull(cut.Find("g[data-test='flow-node'] .fg-foot [data-test='flow-node-subtitle']"));
}
[Fact]
public void Render_StepWithMixedLevels_ShowsCountsHighestSeverityFirst_DebugCountedAsInfo()
{
var logs = new List<FlowLogLine>
{
Line("error"), Line("warn"), Line("WARN"), Line("info"), Line("info"), Line("info"), Line("debug"),
};
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph(logs: logs)));
var badge = cut.Find("[data-test='flow-node-logs']");
Assert.Equal(["✕ 1", "! 2", "≡ 4"], badge.QuerySelectorAll("i").Select(i => i.TextContent.Trim()));
Assert.Equal(["e", "w", "i"], badge.QuerySelectorAll("i").Select(i => i.GetAttribute("class")));
Assert.Equal("1 error, 2 warnings, 4 info", badge.GetAttribute("title"));
}
[Fact]
public void Render_StepWithOnlyInfoLines_ShowsASingleFaintCount()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph(logs: [Line("info"), Line("debug")])));
var badge = cut.Find("[data-test='flow-node-logs']");
var item = Assert.Single(badge.QuerySelectorAll("i"));
Assert.Equal("≡ 2", item.TextContent.Trim());
Assert.Equal("i", item.GetAttribute("class"));
Assert.Equal("2 info", badge.GetAttribute("title"));
}
[Fact]
public void Render_FailedStepWithErrorDetail_SubtitleIsTheExceptionTypeAndMessage()
{
var graph = LogGraph(FlowStepOutcome.Failed, errorMessage: "Recipe 42 rejected",
errorDetail: "System.InvalidOperationException: Recipe 42 rejected\n at X");
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, graph));
Assert.Equal("InvalidOperationException: Recipe 42 rejected",
cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim());
}
[Fact]
public void Render_FailedStepWithoutDetail_FallsBackToTheErrorMessage()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph(FlowStepOutcome.Failed, errorMessage: "timed out talking to MesDb")));
Assert.Equal("timed out talking to MesDb", cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim());
}
[Fact]
public void Render_OkStepWithAnErrorDetail_KeepsItsNormalSubtitle()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph(errorDetail: "System.InvalidOperationException: stale")));
Assert.Equal("trigger: manual", cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim());
}
[Fact]
public void LogCounts_FoldsLevelsCaseInsensitively_AndTreatsEverythingElseAsInfo()
{
Assert.Equal((0, 0, 0), FlowGraphView.LogCounts(null));
Assert.Equal((0, 0, 0), FlowGraphView.LogCounts(LogGraph().Steps[0]));
var step = LogGraph(logs: [Line("Error"), Line("error"), Line("Warn"), Line("info"), Line("debug"), Line("trace")]).Steps[0];
Assert.Equal((2, 1, 3), FlowGraphView.LogCounts(step));
}
[Theory]
[InlineData(FlowStepOutcome.Failed, "System.InvalidOperationException: Recipe 42 rejected\n at X", "boom", "InvalidOperationException: Recipe 42 rejected")]
[InlineData(FlowStepOutcome.TimedOut, "System.TimeoutException: took too long", "boom", "TimeoutException: took too long")]
[InlineData(FlowStepOutcome.Failed, "SomethingWentWrong", "boom", "SomethingWentWrong")]
[InlineData(FlowStepOutcome.Failed, " ", "boom", "boom")]
[InlineData(FlowStepOutcome.Failed, null, "boom", "boom")]
[InlineData(FlowStepOutcome.Ok, "System.InvalidOperationException: nope", "boom", null)]
// A wrapper chains its inner exceptions on the first line — the innermost is the one to act on.
[InlineData(FlowStepOutcome.Failed,
"System.AggregateException: One or more errors occurred. (Recipe 42 rejected) ---> System.InvalidOperationException: Recipe 42 rejected\n at X",
"boom", "InvalidOperationException: Recipe 42 rejected")]
[InlineData(FlowStepOutcome.Failed,
"A.Outer: outer ---> B.Middle: middle ---> C.Innermost: the real one",
"boom", "Innermost: the real one")]
// The namespace is stripped only from the head of the name; a generic suffix stays verbatim.
[InlineData(FlowStepOutcome.Failed, "MyApp.RecipeException`1[System.String]: msg", "boom", "RecipeException`1[System.String]: msg")]
[InlineData(FlowStepOutcome.Failed, "RecipeException`1[System.String]: msg", "boom", "RecipeException`1[System.String]: msg")]
public void ExceptionSubtitle_ParsesTheFirstLineOfTheDetail_OnlyForAFailedStep(
FlowStepOutcome outcome, string? detail, string? message, string? expected)
{
var step = LogGraph(outcome, errorMessage: message, errorDetail: detail).Steps[0];
Assert.Equal(expected, FlowGraphView.ExceptionSubtitle(step));
}
[Fact]
public void LogTitle_NamesOnlyTheNonZeroLevels_WithSingularAndPlural()
{
Assert.Equal("1 error", FlowGraphView.LogTitle((1, 0, 0)));
Assert.Equal("2 errors, 1 warning", FlowGraphView.LogTitle((2, 1, 0)));
Assert.Equal("1 warning, 2 info", FlowGraphView.LogTitle((0, 1, 2)));
Assert.Equal("3 info", FlowGraphView.LogTitle((0, 0, 3)));
Assert.Equal(string.Empty, FlowGraphView.LogTitle((0, 0, 0)));
}
[Fact]
public void Render_OutboundNodeWithLogLines_ShowsBothThePillAndTheBadgeInTheFooter()
{
var correlation = Guid.NewGuid();
var step = new FlowStep(Guid.NewGuid(), Root, null, FlowStepKind.ApiCall, FlowLane.External, "Delmia.AckRecipe",
"Delivered · sync", T0, 212, FlowStepOutcome.Ok, null, null, null, "site-a", "node-a", "M1", "Delmia.AckRecipe", null,
[new FlowLifecycleRow(Guid.NewGuid(), "ApiCall", "Delivered", T0, "node-a", null, 212)],
correlation, null, [Line("warn"), Line("info")]);
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, new FlowGraph([step], [], false, false)));
var foot = cut.Find("g[data-test='flow-node'][data-kind='ApiCall'] .fg-foot");
Assert.Equal("Ok", foot.QuerySelector("[data-test='flow-node-pill']")!.TextContent.Trim());
Assert.Equal("· sync", foot.QuerySelector(".fg-mode")!.TextContent.Trim());
var badge = foot.QuerySelector("[data-test='flow-node-logs']")!;
Assert.Equal(["! 1", "≡ 1"], badge.QuerySelectorAll("i").Select(i => i.TextContent.Trim()));
Assert.Equal("1 warning, 1 info", badge.GetAttribute("title"));
// A pill node has no subtitle line — the pill occupies the footer's left half.
Assert.Null(foot.QuerySelector("[data-test='flow-node-subtitle']"));
}
}