719 lines
38 KiB
C#
719 lines
38 KiB
C#
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("·", "·"));
|
||
}
|
||
|
||
// ── 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']"));
|
||
}
|
||
}
|