From 038eda5d91dd5048b51840526c2a91a1d7d1395c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 6 Sep 2026 01:15:56 -0400 Subject: [PATCH] =?UTF-8?q?fix(central-ui):=20Flow=20Debugger=20node=20tex?= =?UTF-8?q?t=20readability=20=E2=80=94=20HTML=20node=20bodies,=20shared-pr?= =?UTF-8?q?efix=20gutter,=20wrap=20hints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 elements are emitted as raw markup (Razor reserves the 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 --- docs/requirements/Component-CentralUI.md | 2 +- .../Components/Shared/FlowGraphView.razor | 25 ++++- .../Components/Shared/FlowGraphView.razor.cs | 105 ++++++++++++++++-- .../Components/Shared/FlowGraphView.razor.css | 43 +++++-- .../Shared/FlowGraphViewTests.cs | 85 ++++++++++++++ 5 files changed, 236 insertions(+), 24 deletions(-) diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index f3613457..f20f3b1a 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -222,7 +222,7 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms - **Predicted graph first.** Choosing an entry point renders the statically predicted shape (`FlowShapePredictor`, Script Analysis REQ-SA-7) before anything runs — nodes whose target depends on inputs render dashed; after a run, predicted nodes the run never produced render greyed. - **Execution path.** `IFlowDebugService` dispatches `FlowDebugRunMethodCommand` / `FlowDebugRunValueChangeCommand` / `FlowDebugReplayCommand` to the **per-node `ManagementActor`** through `ManagementActorHolder` (the same seam the HTTP `/management` endpoint uses), so the server enforces the Deployer gate and the active-node refusal. A standby node answers the two run commands with `STANDBY_NODE`, which the page renders as an inline alert with the server message verbatim; Replay is served from any node. The same three commands back the CLI's `flow-debug run-method | run-value-change | replay`. - State machine `Idle → Predicted → Running → Completed | Failed | TimedOut`; the status strip shows run number, start, total duration, step/boundary/write counts and the root `ExecutionId` (linking to the Execution Chain page). A run whose route call came back with no child execution and no Site-lane steps shows a *site not traced* note (older site build, or an untraced call). Last entry point and per-method inputs persist in `localStorage`. -- The renderer is the custom-SVG `FlowGraphView` (no third-party charting) — pure, unit-tested layout; culture-invariant coordinates; each node a keyboard-activatable `role="button"`. +- The renderer is the custom-SVG `FlowGraphView` (no third-party charting) — pure, unit-tested layout; culture-invariant coordinates; each node a keyboard-activatable `role="button"`. Node text is HTML inside an SVG `foreignObject` so the browser measures it: the title wraps to two lines and ellipsizes by width (never by character count), the subtitle by width on one line, and the hover tooltip carries the full text. A lane whose real titles share a dotted head (Tags: `DelmiaReceiver.RecipeDownloadFlag`, `DelmiaReceiver.RecipeProcessedFlag`, …) shows that head once in the lane gutter (`DelmiaReceiver.*`) and each node shows only the tail that tells it apart (`FlowGraphView.CommonDottedPrefix`). ### Alarm Summary (Deployment Role) - A dedicated operator **Alarm Summary** page (`/monitoring/alarms`, `RequireDeployment`) gives a **cross-instance, read-only** roll-up of live alarm state at a site — the operator-facing complement to the per-instance Debug View. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor index 6d85e98d..95a8c399 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor @@ -28,7 +28,12 @@ @SvgText("12", LaneLabelY(i), "fg-lane-label", LaneName(i)) @if (!string.IsNullOrEmpty(_layout.LaneSublabels[i])) { - @SvgText("12", F(i * LaneHeight + 40), "fg-lane-sublabel", Clip(_layout.LaneSublabels[i], 16), title: _layout.LaneSublabels[i]) + @SvgText("12", F(i * LaneHeight + 40), "fg-lane-sublabel", Clip(_layout.LaneSublabels[i], 22), title: _layout.LaneSublabels[i]) + } + @if (!string.IsNullOrEmpty(_layout.LanePrefixes[i])) + { + @* The dotted head every node in this lane shares — shown here once, stripped from the nodes. *@ + @SvgText("12", F(i * LaneHeight + 56), "fg-lane-prefix", Clip(_layout.LanePrefixes[i] + "*", 22), title: _layout.LanePrefixes[i] + "* (prefix shared by every node in this lane)", dataTest: "flow-lane-prefix") } } @@ -53,12 +58,20 @@ aria-label="@(Eyebrow(node) + ": " + node.Title)" data-test="flow-node" data-key="@node.Key" data-kind="@node.Kind" data-lane="@node.Lane" data-column="@node.Column" @onclick="() => SelectAsync(node)" @onkeydown="e => OnKeyAsync(e, node)"> - @node.Title@(string.IsNullOrEmpty(node.Subtitle) ? "" : " — " + node.Subtitle) - @SvgText("10", "16", "fg-eyebrow", Eyebrow(node)) - @SvgText(F(NodeWidth - 10), "16", "fg-duration", Duration(node), anchor: "end") - @SvgText("10", "40", "fg-title", Clip(node.Title, 20)) - @SvgText("10", "60", "fg-subtitle", Clip(node.Subtitle, 24)) + @* HTML body inside the SVG so the browser measures the text: the title wraps to two + lines and both lines ellipsize by real width, never by a character count, and + nothing can spill past the box. The tooltip carries the full, unstripped text. *@ + +
+
+ @Eyebrow(node) + @Duration(node) +
+
@WrapHints(node.DisplayTitle)
+
@node.DisplaySubtitle
+
+
} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs index fd107bea..fdd5f487 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs @@ -30,6 +30,21 @@ public sealed record FlowLayoutNode( /// Display subtitle (real: the step's; predicted: the note). public string? Subtitle => Step?.Subtitle ?? Predicted?.Note; + + /// + /// The dotted prefix shared by every real title in this node's lane (e.g. DelmiaReceiver.), + /// factored out of the node text and shown once in the lane gutter; null when the lane has none. + /// + public string? LanePrefix { get; init; } + + /// Title with removed — the discriminating tail of a dotted path. + public string DisplayTitle => FlowGraphView.StripPrefix(Title, LanePrefix) ?? Title; + + /// Subtitle with removed from each comma-separated item. + public string? DisplaySubtitle => FlowGraphView.StripPrefix(Subtitle, LanePrefix); + + /// Full, unclipped text for the hover tooltip. + public string FullText => string.IsNullOrEmpty(Subtitle) ? Title : Title + " — " + Subtitle; } /// One laid-out connector. @@ -47,12 +62,14 @@ public sealed record FlowLayoutEdge(string From, string To, string Path, bool Cr /// Canvas width in SVG units. /// Canvas height in SVG units. /// Per-lane sublabel (index = ), empty when unknown. +/// Per-lane dotted prefix factored out of the node titles (index = ), empty when none. public sealed record FlowLayout( IReadOnlyList Nodes, IReadOnlyList Edges, double Width, double Height, - IReadOnlyList LaneSublabels) + IReadOnlyList LaneSublabels, + IReadOnlyList LanePrefixes) { /// True when there is nothing to draw. public bool IsEmpty => Nodes.Count == 0; @@ -72,10 +89,10 @@ public partial class FlowGraphView : ComponentBase public const double LaneHeight = 110; /// Node width. public const double NodeWidth = 150; - /// Node height. - public const double NodeHeight = 78; - /// Left edge of column 0 (the lane label gutter sits to its left). - public const double ColumnOrigin = 130; + /// Node height (eyebrow row + a two-line title + subtitle inside the lane's 110). + public const double NodeHeight = 84; + /// Left edge of column 0 (the lane label gutter sits to its left; wide enough for a 22-character mono sublabel). + public const double ColumnOrigin = 160; /// Column pitch. public const double ColumnPitch = 170; /// Top padding inside a lane. @@ -175,13 +192,70 @@ public partial class FlowGraphView : ComponentBase private static string Duration(FlowLayoutNode n) => n.Step?.DurationMs is int ms ? ms.ToString(CultureInfo.InvariantCulture) + " ms" : (n.PredictedOnly ? "predicted" : string.Empty); - /// Clips a label to what fits the node width, keeping the full text in a title. + /// Clips a gutter label to what fits, keeping the full text in a title. public static string Clip(string? text, int max) { if (string.IsNullOrEmpty(text)) return string.Empty; return text.Length <= max ? text : text[..(max - 1)] + "…"; } + /// + /// The longest dotted prefix (ending in .) shared by every title, or null when there are + /// fewer than two titles, no common prefix, or stripping it would leave some title empty. Node + /// titles on the Tags lane are attribute paths that mostly differ in their last segment, so the + /// shared head is shown once in the lane gutter and each node shows the tail that tells it apart. + /// + public static string? CommonDottedPrefix(IReadOnlyList titles) + { + if (titles.Count < 2) return null; + var prefix = titles[0]; + foreach (var t in titles.Skip(1)) + { + var n = 0; + while (n < prefix.Length && n < t.Length && prefix[n] == t[n]) n++; + prefix = prefix[..n]; + if (prefix.Length == 0) return null; + } + + var dot = prefix.LastIndexOf('.'); + if (dot < 0) return null; + prefix = prefix[..(dot + 1)]; + return titles.All(t => t.Length > prefix.Length) ? prefix : null; + } + + /// + /// Inserts zero-width spaces (U+200B) after ., /, _ and at lower→upper + /// camel-case boundaries so a wrapped title breaks at DelmiaRecipe|Download rather than + /// mid-word; the browser still falls back to breaking anywhere for a single overlong segment. + /// + public static string WrapHints(string? text) + { + if (string.IsNullOrEmpty(text)) return string.Empty; + var sb = new StringBuilder(text.Length + 8); + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (i > 0 && char.IsUpper(c) && char.IsLower(text[i - 1])) + sb.Append('\u200B'); + sb.Append(c); + if (c is '.' or '/' or '_' && i < text.Length - 1) + sb.Append('\u200B'); + } + + return sb.ToString(); + } + + /// + /// Removes where it starts or a comma-separated + /// item within it (the write subtitle is a.X = 1, a.Y = 2); null/empty text passes through. + /// + public static string? StripPrefix(string? text, string? prefix) + { + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(prefix)) return text; + var s = text.StartsWith(prefix, StringComparison.Ordinal) ? text[prefix.Length..] : text; + return s.Replace(", " + prefix, ", ", StringComparison.Ordinal); + } + /// /// Emits an SVG <text> element as pre-encoded markup. Razor reserves the /// <text> tag for its own markup-mode switch and refuses attributes on it, @@ -336,6 +410,16 @@ public partial class FlowGraphView : ComponentBase maxCol = Math.Max(maxCol, col); } + // A lane whose real titles share a dotted head (Tags: "DelmiaReceiver.X", "DelmiaReceiver.Y") + // shows that head once in the gutter and only the tail on each node. + var prefixes = new string?[LaneCount]; + for (var l = 0; l < LaneCount; l++) + { + var titles = keys.Where(k => lane[k] == (FlowLane)l && stepByKey.ContainsKey(k)) + .Select(k => stepByKey[k].Title).ToList(); + prefixes[l] = CommonDottedPrefix(titles); + } + foreach (var k in keys) { var col = column[k]; @@ -343,7 +427,10 @@ public partial class FlowGraphView : ComponentBase var y = LanePad + (int)lane[k] * LaneHeight; stepByKey.TryGetValue(k, out var step); predByKey.TryGetValue(k, out var pn); - nodes.Add(new FlowLayoutNode(k, lane[k], col, x, y, step, pn, ghostKeys.Contains(k))); + nodes.Add(new FlowLayoutNode(k, lane[k], col, x, y, step, pn, ghostKeys.Contains(k)) + { + LanePrefix = step is null ? null : prefixes[(int)lane[k]], + }); } var byKey = nodes.ToDictionary(n => n.Key, StringComparer.Ordinal); @@ -358,7 +445,9 @@ public partial class FlowGraphView : ComponentBase if (maxCol < 0) width = ColumnOrigin + ColumnPitch + RightPad; var height = LaneCount * LaneHeight + LanePad; - return new FlowLayout(nodes, edges, width, height, sublabels.Select(s => s ?? string.Empty).ToArray()); + return new FlowLayout(nodes, edges, width, height, + sublabels.Select(s => s ?? string.Empty).ToArray(), + prefixes.Select(p => p ?? string.Empty).ToArray()); } private static string JoinLabel(string? a, string? b) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css index 2c6a5598..f6cf74ff 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css @@ -15,13 +15,15 @@ font-family: var(--bs-body-font-family, system-ui, sans-serif); } -/* Lanes */ +/* Lanes. The gutter labels are emitted as raw markup (SvgText — Razor reserves the tag), + so they carry no CSS-isolation scope attribute and must be reached through ::deep. */ .fg-lane-bg { fill: transparent; } .fg-lane-bg.alt { fill: var(--rule); fill-opacity: 0.25; } .fg-lane-rule { stroke: var(--rule); stroke-width: 1; } -.fg-lane-label { fill: var(--ink-soft); font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; } -.fg-lane-sublabel{ fill: var(--ink-faint); font-size: 0.65rem; font-family: var(--mono); } -.fg-empty { fill: var(--ink-faint); font-size: 0.85rem; font-style: italic; } +.fg-wrap ::deep .fg-lane-label { fill: var(--ink-soft); font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; } +.fg-wrap ::deep .fg-lane-sublabel{ fill: var(--ink-faint); font-size: 0.65rem; font-family: var(--mono); } +.fg-wrap ::deep .fg-lane-prefix { fill: var(--ink-faint); font-size: 0.65rem; font-family: var(--mono); } +.fg-wrap ::deep .fg-empty { fill: var(--ink-faint); font-size: 0.85rem; font-style: italic; } /* Nodes */ .fg-node { cursor: pointer; outline: none; } @@ -45,12 +47,35 @@ .fg-node.timed-out .fg-node-box { stroke: var(--bs-danger, #c0392b); } .fg-node.not-traced .fg-node-box { stroke-dasharray: 1 3; } -.fg-eyebrow { fill: var(--ink-faint); font-size: 0.6rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; } -.fg-duration { fill: var(--ink-soft); font-size: 0.6rem; font-family: var(--mono); } -.fg-title { fill: var(--ink); font-size: 0.8rem; font-weight: 600; } -.fg-subtitle { fill: var(--ink-soft); font-size: 0.68rem; } +/* Node body: HTML inside a foreignObject so text is measured by the browser. */ +.fg-body { + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 6px 8px 5px; + display: flex; + flex-direction: column; + gap: 1px; + overflow: hidden; + line-height: 1.2; + color: var(--ink); + font-family: var(--bs-body-font-family, system-ui, sans-serif); +} +.fg-head { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; } +.fg-eyebrow { color: var(--ink-faint); font-size: 0.6rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.fg-duration { color: var(--ink-soft); font-size: 0.6rem; font-family: var(--mono); white-space: nowrap; flex-shrink: 0; } +.fg-title { + font-size: 0.78rem; + font-weight: 600; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.fg-subtitle { color: var(--ink-soft); font-size: 0.66rem; margin-top: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .fg-node.failed .fg-eyebrow, -.fg-node.timed-out .fg-eyebrow { fill: var(--bs-danger, #c0392b); } +.fg-node.timed-out .fg-eyebrow { color: var(--bs-danger, #c0392b); } /* Connectors */ .fg-edge { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs index 73dc51c3..15fa9b56 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs @@ -216,4 +216,89 @@ public class FlowGraphViewTests : BunitContext 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("·", "·")); + } }