Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs
T
Joseph Doherty 56154fd1d2 feat(central-ui): FlowGraphView External lane, outbound status pills, async/spawn connectors
Four lanes (LaneCount 4; External sublabel = distinct targets, three then +n);
ApiCall/DbCall/Notify nodes carry a status pill (Ok/Pending/Parked/Failed —
pending pulses, reduced-motion aware) plus a sync/cached/notify mode; folded
lifecycles show '+4.1 s' submit→terminal wall time. Async edges are dashed
and routed through the inter-lane strip with a gutter tag; spawn edges use a
bolt marker. Predicted async edges (Task 6's PredictedEdge.Async) render the
same way. de-DE height pinned at 319.2 = (4×110+16)×0.7.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 01:57:47 -04:00

501 lines
27 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 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_SpawnEdgeUsesTheBoltMarker()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, OutboundGraph()));
Assert.NotNull(cut.Find("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)", spawn.GetAttribute("marker-end"));
Assert.All(edges.Where(e => !e.ClassList.Contains("spawn")), e => Assert.NotEqual("url(#fg-arrow-spawn)", e.GetAttribute("marker-end")));
Assert.All(edges, e => Assert.DoesNotContain(",", e.GetAttribute("d")));
}
[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);
}
}