Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs
T
Joseph Doherty 859922ba17 fix(central-ui): Flow Debugger spawn edges — real arrowhead + bolt badge; spawner picked by trigger attribute
The trigger-cascade (Spawn) edge used a lightning-bolt marker IN PLACE of the arrowhead. On the upward
Tags→Site hop the bolt sat on top of the riser and read as a broken arrow (rig screenshot 2026-09-08).
Every connector now ends in an ordinary arrowhead; the bolt is a badge (circle + bolt) centred on the
connector's vertical run (FlowGraphView.SpawnBadgePoint, kept in step with OrthogonalPath/AsyncPath).

Same screenshot, second defect: the cascade spawned by "RecipeDownloadFlag = false" was drawn from the
LAST write of the burst (RecipeProcessedFlag) because all four writes share a millisecond and the
spawner rule was time-only. FlowGraphBuilder now prefers the parent's last AttributeWrite of the attribute
the child's triggerAttribute names (at or before the child, else the last such write); the time/position
rules remain the fallback when the child names no attribute or the parent never wrote it.

Verified live on the rebuilt rig (DelmiaSim01 / DelmiaReceiverSimulator): both spawn edges show an
arrowhead + badge, the second one originates from the RecipeDownloadFlag write. bUnit + builder tests
updated/added; Playwright FlowDebuggerTests 4/4.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:04:39 -04:00

570 lines
31 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);
}
}