038eda5d91
The swimlane's node text was clipped by character count (20/24), so dotted attribute
paths showed only their shared head ("DelmiaReceiver.Reci…" five times in a row) and
the subtitle spilled past the box. It was also unstyled: the SVG <text> elements are
emitted as raw markup (Razor reserves the <text> tag), so they never carried the CSS
isolation scope attribute and every .fg-* rule silently missed.
- Node body is now HTML inside an SVG foreignObject: the browser measures the text,
the title wraps to two lines and ellipsizes by width, the subtitle by width on one
line, nothing can overflow the box, and the div's title attribute carries the full
unstripped text as a tooltip. Node height 78 -> 84.
- A lane whose real titles share a dotted head (CommonDottedPrefix) shows it once in
the lane gutter ("DelmiaReceiver.*") and each node shows only the tail that tells
it apart; the write subtitle strips the same head per item. Predicted-only nodes
are never stripped.
- WrapHints inserts zero-width spaces at camel-case and ./_/ boundaries so a wrapped
title breaks at "DelmiaRecipe|Download" rather than mid-word.
- Gutter widened 130 -> 160 with the sublabel clip raised to 22 so "site-a · DelmiaSim01"
fits; the remaining SvgText gutter labels are styled through ::deep.
Verified live on the rig (replay of the Task 22 run): all three lanes readable, no
overflow, gutter shows the factored prefix. CentralUI suite 1199 pass.
Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
305 lines
15 KiB
C#
305 lines
15 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(3, cut.FindAll("g.fg-lane").Count);
|
||
Assert.NotNull(cut.Find("[data-test='flow-graph-empty']"));
|
||
Assert.Empty(cut.FindAll("g[data-test='flow-node']"));
|
||
Assert.Contains("Central", cut.Markup);
|
||
Assert.Contains("Tags", cut.Markup);
|
||
}
|
||
|
||
[Fact]
|
||
public void Render_PredictionOnly_DrawsDashedPredictedNodesAndEdges()
|
||
{
|
||
var predicted = FlowShapePredictor.PredictInboundMethod("M",
|
||
"""return await Route.To(code).Call("S", new { });""", (_, _) => "await Attributes.SetAsync(\"X\", 1);");
|
||
|
||
var cut = Render<FlowGraphView>(p => p.Add(c => c.Predicted, predicted));
|
||
|
||
var nodes = cut.FindAll("g[data-test='flow-node']");
|
||
Assert.Equal(predicted.Nodes.Count, nodes.Count);
|
||
Assert.All(nodes, n => Assert.Contains("predicted", n.ClassList));
|
||
Assert.Contains(nodes, n => n.ClassList.Contains("input-dependent"));
|
||
Assert.All(cut.FindAll("path[data-test='flow-edge']"), e => Assert.Contains("predicted", e.ClassList));
|
||
Assert.DoesNotContain(nodes, n => n.ClassList.Contains("ghost"));
|
||
}
|
||
|
||
[Fact]
|
||
public void Render_GraphWithPrediction_GreysPredictedNodesTheRunNeverProduced()
|
||
{
|
||
var graph = DelmiaGraph();
|
||
// Prediction with one extra wait the run did not perform.
|
||
var predicted = new PredictedFlow(
|
||
[
|
||
new("n1", FlowStepKind.InboundRequest, FlowLane.Central, "POST /api/DelmiaRecipeDownload", false, null),
|
||
new("n2", FlowStepKind.RouteCall, FlowLane.Central, "Route.To(code)", true, null),
|
||
new("n3", FlowStepKind.ScriptRun, FlowLane.Site, "ProcessRecipeDownload", false, null),
|
||
new("n4", FlowStepKind.AttributeWait, FlowLane.Site, "DelmiaSim01.RecipeProcessedFlag", false, null),
|
||
new("n5", FlowStepKind.AttributeWait, FlowLane.Site, "DelmiaSim01.NeverWaited", false, null),
|
||
new("n6", FlowStepKind.Response, FlowLane.Central, "Response", false, null),
|
||
], []);
|
||
|
||
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, graph).Add(c => c.Predicted, predicted));
|
||
|
||
var nodes = cut.FindAll("g[data-test='flow-node']");
|
||
Assert.Equal(8, nodes.Count); // 7 real + 1 ghost
|
||
var ghost = Assert.Single(nodes, n => n.ClassList.Contains("ghost"));
|
||
Assert.Equal("p:n5", ghost.GetAttribute("data-key"));
|
||
Assert.Equal("Site", ghost.GetAttribute("data-lane"));
|
||
// The ghost sits after every real column.
|
||
var realMax = nodes.Where(n => !n.ClassList.Contains("ghost")).Max(n => int.Parse(n.GetAttribute("data-column")!));
|
||
Assert.True(int.Parse(ghost.GetAttribute("data-column")!) > realMax);
|
||
}
|
||
|
||
[Fact]
|
||
public void Render_UnderGermanCulture_CoordinatesStayInvariant()
|
||
{
|
||
var previous = CultureInfo.CurrentCulture;
|
||
var previousUi = CultureInfo.CurrentUICulture;
|
||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||
CultureInfo.CurrentUICulture = new CultureInfo("de-DE");
|
||
try
|
||
{
|
||
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, DelmiaGraph()).Add(c => c.Zoom, 0.7));
|
||
|
||
var svg = cut.Find("svg");
|
||
// 346 × 0.7 = 242.2 — a comma here would be the German decimal separator leaking through.
|
||
Assert.Equal("242.2", svg.GetAttribute("height"));
|
||
Assert.DoesNotContain(",", svg.GetAttribute("viewBox"));
|
||
foreach (var path in cut.FindAll("path[data-test='flow-edge']"))
|
||
Assert.DoesNotContain(",", path.GetAttribute("d"));
|
||
}
|
||
finally
|
||
{
|
||
CultureInfo.CurrentCulture = previous;
|
||
CultureInfo.CurrentUICulture = previousUi;
|
||
}
|
||
}
|
||
|
||
// ── 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("·", "·"));
|
||
}
|
||
}
|