fix(central-ui): Flow Debugger node text readability — HTML node bodies, shared-prefix gutter, wrap hints
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
This commit is contained in:
@@ -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<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("·", "·"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user