fix(central-ui): exception subtitle parses inner/generic exception names; pill+badge test (Task 11 review)

This commit is contained in:
Joseph Doherty
2026-09-09 11:47:58 -04:00
parent a4a35bfa97
commit 547aaefb02
3 changed files with 51 additions and 12 deletions
@@ -104,7 +104,7 @@
}
@if (logs.Errors + logs.Warnings + logs.Info > 0)
{
<span class="fg-logs" data-test="flow-node-logs" title="@LogTitle(node)">
<span class="fg-logs" data-test="flow-node-logs" title="@LogTitle(logs)">
@if (logs.Errors > 0)
{
<i class="e">✕ @logs.Errors</i>
@@ -260,9 +260,9 @@ public partial class FlowGraphView : ComponentBase
}
/// <summary>Hover text for the log badge — the non-zero levels only ("1 error, 2 warnings, 4 info").</summary>
internal static string LogTitle(FlowLayoutNode n)
internal static string LogTitle((int Errors, int Warnings, int Info) counts)
{
var (errors, warnings, info) = LogCounts(n.Step);
var (errors, warnings, info) = counts;
var parts = new List<string>(3);
if (errors > 0) parts.Add($"{errors} error{(errors == 1 ? "" : "s")}");
if (warnings > 0) parts.Add($"{warnings} warning{(warnings == 1 ? "" : "s")}");
@@ -274,7 +274,10 @@ public partial class FlowGraphView : ComponentBase
/// The subtitle a failed or timed-out step shows instead of its own: <c>{Type}: {message}</c>, where
/// the type is the exception's name without its namespace, parsed from the first line of the captured
/// <see cref="FlowStep.ErrorDetail"/> (a .NET <c>ToString()</c> opens with <c>Namespace.Type: message</c>).
/// Falls back to the first line verbatim when it carries no <c>": "</c>, and to
/// A wrapper's first line chains its inner exceptions with <c>" ---&gt; "</c>; the LAST segment is taken,
/// since the innermost exception is the one the script author has to act on. The namespace is stripped
/// only from the head of the type name — any generic arity/argument suffix (<c>`1[System.String]</c>)
/// is kept verbatim. Falls back to the line itself when it carries no <c>": "</c>, and to
/// <see cref="FlowStep.ErrorMessage"/> when there is no detail. Null for every non-failed step.
/// </summary>
internal static string? ExceptionSubtitle(FlowStep s)
@@ -283,6 +286,9 @@ public partial class FlowGraphView : ComponentBase
if (string.IsNullOrWhiteSpace(s.ErrorDetail)) return s.ErrorMessage;
var first = s.ErrorDetail.Split('\n', 2)[0].TrimEnd('\r').Trim();
// A wrapper (AggregateException, a rethrow) chains inner exceptions on the same line — the last is innermost.
var inner = first.LastIndexOf(" ---> ", StringComparison.Ordinal);
if (inner >= 0) first = first[(inner + " ---> ".Length)..].Trim();
if (first.Length == 0) return s.ErrorMessage;
var split = first.IndexOf(": ", StringComparison.Ordinal);
@@ -290,8 +296,12 @@ public partial class FlowGraphView : ComponentBase
var type = first[..split];
var message = first[(split + 2)..];
var dot = type.LastIndexOf('.');
if (dot >= 0 && dot < type.Length - 1) type = type[(dot + 1)..];
// Shorten only the leading namespace: everything from a generic marker on is part of the name.
var generic = type.IndexOfAny(['`', '[']);
var head = generic < 0 ? type : type[..generic];
var dot = head.LastIndexOf('.');
if (dot >= 0 && dot < head.Length - 1)
type = head[(dot + 1)..] + (generic < 0 ? string.Empty : type[generic..]);
return type + ": " + message;
}
@@ -667,6 +667,16 @@ public class FlowGraphViewTests : BunitContext
[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)
{
@@ -678,12 +688,31 @@ public class FlowGraphViewTests : BunitContext
[Fact]
public void LogTitle_NamesOnlyTheNonZeroLevels_WithSingularAndPlural()
{
FlowLayoutNode Node(IReadOnlyList<FlowLogLine>? logs)
=> new("k", FlowLane.Site, 0, 0, 0, LogGraph(logs: logs).Steps[0], null, false);
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)));
}
Assert.Equal("1 error", FlowGraphView.LogTitle(Node([Line("error")])));
Assert.Equal("2 errors, 1 warning", FlowGraphView.LogTitle(Node([Line("error"), Line("error"), Line("warn")])));
Assert.Equal("1 warning, 2 info", FlowGraphView.LogTitle(Node([Line("warn"), Line("info"), Line("debug")])));
Assert.Equal(string.Empty, FlowGraphView.LogTitle(Node(null)));
[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']"));
}
}