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; /// /// Tests for (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. /// 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); /// Inbound → route call → site run → two writes → wait (+ synthesized response) — the Delmia shape. private static FlowGraph DelmiaGraph() { var events = new List { 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 { 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(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(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(); 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(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(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(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(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("·", "·")); } }