diff --git a/Darling/Darling.Tests/PlanViewerRuntimeSummaryNestingTests.cs b/Darling/Darling.Tests/PlanViewerRuntimeSummaryNestingTests.cs new file mode 100644 index 000000000..dd0fc9b10 --- /dev/null +++ b/Darling/Darling.Tests/PlanViewerRuntimeSummaryNestingTests.cs @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitor.PlanAnalysis; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4836: the Runtime Summary card nests the early abort reason under Optimization, as +/// erikdarlingdata/PerformanceStudio#614 does. The nested row's LABEL gets a 12px left indent and its VALUE +/// stays in the same column as every other value. pins which row is nested (the +/// pure row model, which runs anywhere); these render the card in a live and +/// read the margins back, so they also prove the control applies the indent to the label and only the label. +/// They need STA and WPF. ShowRuntimeSummary stays private, called through reflection (the shape +/// PlanViewer4632FallbackTests in Lite.Tests uses for CriticalOrangeBrush), so this adds no +/// product surface; is not used because it awaits Task.Run +/// and nothing here pumps a dispatcher. +/// +public sealed class PlanViewerRuntimeSummaryNestingTests +{ + private static PlanStatement StatementWith(string? optimizationLevel, string? earlyAbortReason) => new() + { + StatementText = "SELECT 1", + StatementOptmLevel = optimizationLevel, + StatementOptmEarlyAbortReason = earlyAbortReason, + CardinalityEstimationModelVersion = 160 + }; + + [Fact] + public void RuntimeSummary_EarlyAbortUnderOptimization_IndentsItsLabelOnly() + { + OnStaThread(() => + { + var control = new PlanViewerControl(); + try + { + var grid = RenderRuntimeSummary(control, StatementWith("FULL", "TimeOut")); + + var ceModel = RowOf(grid, "CE model"); + var optimization = RowOf(grid, "Optimization"); + var earlyAbort = RowOf(grid, "Early abort"); + + // Optimization, then its reason on the next row, at the end of the card. + Assert.Equal(Grid.GetRow(ceModel.Label) + 1, Grid.GetRow(optimization.Label)); + Assert.Equal(Grid.GetRow(optimization.Label) + 1, Grid.GetRow(earlyAbort.Label)); + Assert.Equal(grid.RowDefinitions.Count - 1, Grid.GetRow(earlyAbort.Label)); + + // The label alone moves: 12px in, every other margin edge as on the un-nested rows. + Assert.Equal(new Thickness(12, 1, 8, 1), earlyAbort.Label.Margin); + Assert.Equal(new Thickness(0, 1, 8, 1), optimization.Label.Margin); + Assert.Equal(new Thickness(0, 1, 8, 1), ceModel.Label.Margin); + + // The value stays where every other value sits: same column, same margin. + Assert.Equal("TimeOut", earlyAbort.Value.Text); + Assert.Equal(1, Grid.GetColumn(earlyAbort.Value)); + Assert.Equal(Grid.GetColumn(optimization.Value), Grid.GetColumn(earlyAbort.Value)); + Assert.Equal(optimization.Value.Margin, earlyAbort.Value.Margin); + + // Nothing but the early abort label is indented. + Assert.All( + grid.Children.OfType().Where(t => Grid.GetColumn(t) == 0 && t.Text != "Early abort"), + t => Assert.Equal(0d, t.Margin.Left)); + } + finally + { + control.Cleanup(); + } + }); + } + + [Fact] + public void RuntimeSummary_EarlyAbortWithNoOptimizationRow_IsNotIndented() + { + OnStaThread(() => + { + var control = new PlanViewerControl(); + try + { + var grid = RenderRuntimeSummary(control, StatementWith(optimizationLevel: null, earlyAbortReason: "TimeOut")); + + var earlyAbort = RowOf(grid, "Early abort"); + + Assert.Equal(new Thickness(0, 1, 8, 1), earlyAbort.Label.Margin); + Assert.Equal(1, Grid.GetColumn(earlyAbort.Value)); + } + finally + { + control.Cleanup(); + } + }); + } + + /* ── card lookups ── */ + + /// Runs the control's private ShowRuntimeSummary and returns the card's grid. + private static Grid RenderRuntimeSummary(PlanViewerControl control, PlanStatement statement) + { + var method = typeof(PlanViewerControl).GetMethod("ShowRuntimeSummary", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.True(method is not null, "PlanViewerControl.ShowRuntimeSummary no longer exists under that name - this pin's reflection anchor moved."); + method!.Invoke(control, new object[] { statement }); + + return Assert.IsType(Assert.Single(control.RuntimeSummaryContent.Children)); + } + + /// The label (column 0) and value (column 1) TextBlocks of the card row with this label. + private static (TextBlock Label, TextBlock Value) RowOf(Grid grid, string label) + { + var texts = grid.Children.OfType().ToList(); + var labelText = texts.Single(t => Grid.GetColumn(t) == 0 && t.Text == label); + var valueText = texts.Single(t => Grid.GetColumn(t) == 1 && Grid.GetRow(t) == Grid.GetRow(labelText)); + return (labelText, valueText); + } + + /// WPF objects require STA; same shape as the other WPF tests here. + private static void OnStaThread(Action body) + { + Exception? error = null; + var thread = new Thread(() => + { + try { body(); } + catch (Exception ex) { error = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (error is not null) + { + throw error; + } + } +} diff --git a/Darling/Darling.Tests/Viewer4570Tests.cs b/Darling/Darling.Tests/Viewer4570Tests.cs index 00b2a61db..ff726f854 100644 --- a/Darling/Darling.Tests/Viewer4570Tests.cs +++ b/Darling/Darling.Tests/Viewer4570Tests.cs @@ -132,7 +132,7 @@ public void MemoryGrantColorKey_LowUtilizationNoSpill_IsError() // --- BuildRuntimeSummaryRows: row order (E11) ------------------------------------------ [Fact] - public void BuildRuntimeSummaryRows_ActualPlan_OrdersElapsedCpuElapsedDopCpuCompileMemoryOptCe() + public void BuildRuntimeSummaryRows_ActualPlan_OrdersElapsedCpuElapsedDopCpuCompileMemoryCeOptEarlyAbort() { var stmt = Statement(); stmt.QueryTimeStats = new QueryTimeInfo { ElapsedTimeMs = 1000, CpuTimeMs = 2000 }; @@ -140,15 +140,52 @@ public void BuildRuntimeSummaryRows_ActualPlan_OrdersElapsedCpuElapsedDopCpuComp stmt.CompileTimeMs = 5; stmt.MemoryGrant = new MemoryGrantInfo { GrantedMemoryKB = 1024, MaxUsedMemoryKB = 512 }; stmt.StatementOptmLevel = "FULL"; + stmt.StatementOptmEarlyAbortReason = "TimeOut"; stmt.CardinalityEstimationModelVersion = 160; var labels = PlanDisplayText.BuildRuntimeSummaryRows(stmt).Select(r => r.Label).ToArray(); + // #4836 (PerformanceStudio#613/#614): CE model moved above Optimization, and Early abort + // follows Optimization, so Optimization and its reason end the card. Assert.Equal( - new[] { "Elapsed", "CPU:Elapsed", "DOP", "CPU", "Compile", "Memory grant", "Optimization", "CE model" }, + new[] { "Elapsed", "CPU:Elapsed", "DOP", "CPU", "Compile", "Memory grant", "CE model", "Optimization", "Early abort" }, labels); } + // --- #4836: the early abort reason nests under Optimization ---------------------------- + + [Fact] + public void BuildRuntimeSummaryRows_EarlyAbortWithOptimizationLevel_IsNestedAndNothingElseIs() + { + var stmt = Statement(); + stmt.QueryTimeStats = new QueryTimeInfo { ElapsedTimeMs = 1000, CpuTimeMs = 2000 }; + stmt.StatementOptmLevel = "FULL"; + stmt.StatementOptmEarlyAbortReason = "GoodEnoughPlanFound"; + stmt.CardinalityEstimationModelVersion = 160; + + var rows = PlanDisplayText.BuildRuntimeSummaryRows(stmt); + + var earlyAbort = rows.Single(r => r.Label == "Early abort"); + Assert.Equal("GoodEnoughPlanFound", earlyAbort.Value); + Assert.True(earlyAbort.Nested); + Assert.False(rows.Single(r => r.Label == "Optimization").Nested); + Assert.All(rows.Where(r => r.Label != "Early abort"), r => Assert.False(r.Nested, r.Label)); + } + + [Fact] + public void BuildRuntimeSummaryRows_EarlyAbortWithoutOptimizationLevel_IsNotNested() + { + var stmt = Statement(); + stmt.StatementOptmEarlyAbortReason = "TimeOut"; + stmt.CardinalityEstimationModelVersion = 160; + + var rows = PlanDisplayText.BuildRuntimeSummaryRows(stmt); + + // No Optimization row to sit under, so the reason is a plain row, still after CE model. + Assert.Equal(new[] { "CE model", "Early abort" }, rows.Select(r => r.Label).ToArray()); + Assert.All(rows, r => Assert.False(r.Nested, r.Label)); + } + [Fact] public void BuildRuntimeSummaryRows_EstimatedPlan_SkipsRuntimeOnlyRows() { diff --git a/PerformanceMonitor.PlanAnalysis/PlanDisplayText.cs b/PerformanceMonitor.PlanAnalysis/PlanDisplayText.cs index 502b52fdf..83fcfb9f6 100644 --- a/PerformanceMonitor.PlanAnalysis/PlanDisplayText.cs +++ b/PerformanceMonitor.PlanAnalysis/PlanDisplayText.cs @@ -7,8 +7,10 @@ namespace PerformanceMonitor.PlanAnalysis; /// /// One row of the runtime summary card: a label, a display value, and an optional theme brush /// resource key ("ErrorBrush"/"WarningBrush"), where null means the card's default value color. +/// Nested marks a row that is a detail of the row above it (the early abort reason under +/// Optimization): the card indents its label, and its value stays in the same column as every other. /// -public readonly record struct RuntimeSummaryRow(string Label, string Value, string? ColorKey = null); +public readonly record struct RuntimeSummaryRow(string Label, string Value, string? ColorKey = null, bool Nested = false); /// /// Small, pure display-text helpers shared by every plan-analysis surface (viewer, MCP, drill-down), @@ -143,9 +145,12 @@ public static string FormatMemoryGrantKB(long kb) /// /// Builds the runtime summary card's rows in erikdarlingdata/PerformanceStudio@40ade29 (E11)'s /// order: Elapsed, CPU:Elapsed, DOP (or Serial reason), CPU, UDF CPU, UDF elapsed, Compile, - /// Cached plan size, Memory grant, Branches, Threads, Optimization, Early abort, CE model. A row - /// is omitted entirely when its underlying value isn't present, matching the WPF card's own - /// omission rules. + /// Cached plan size, Memory grant, Branches, Threads, then, as erikdarlingdata/PerformanceStudio#613 + /// and #614 reordered them, CE model, Optimization, Early abort. The early abort reason is part + /// of the optimization result rather than a fact of its own, so its row sits under Optimization + /// and is when the Optimization row is present; with no + /// optimization level there is nothing to nest under and it is a plain row. A row is omitted + /// entirely when its underlying value isn't present, matching the WPF card's own omission rules. /// public static IReadOnlyList BuildRuntimeSummaryRows( PlanStatement statement) @@ -248,12 +253,13 @@ public static IReadOnlyList BuildRuntimeSummaryRows( } } - if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) - rows.Add(new RuntimeSummaryRow("Optimization", statement.StatementOptmLevel)); - if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) - rows.Add(new RuntimeSummaryRow("Early abort", statement.StatementOptmEarlyAbortReason)); if (statement.CardinalityEstimationModelVersion > 0) rows.Add(new RuntimeSummaryRow("CE model", statement.CardinalityEstimationModelVersion.ToString())); + var hasOptimizationRow = !string.IsNullOrEmpty(statement.StatementOptmLevel); + if (hasOptimizationRow) + rows.Add(new RuntimeSummaryRow("Optimization", statement.StatementOptmLevel!)); + if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) + rows.Add(new RuntimeSummaryRow("Early abort", statement.StatementOptmEarlyAbortReason, Nested: hasOptimizationRow)); return rows; } diff --git a/PerformanceMonitor.Ui/PlanViewerControl.Properties.cs b/PerformanceMonitor.Ui/PlanViewerControl.Properties.cs index 6b2dbac08..a377cc920 100644 --- a/PerformanceMonitor.Ui/PlanViewerControl.Properties.cs +++ b/PerformanceMonitor.Ui/PlanViewerControl.Properties.cs @@ -1610,7 +1610,10 @@ private void ShowRuntimeSummary(PlanStatement statement) grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); int rowIndex = 0; - void AddRow(string label, string value, string? colorKey) + // nested (#4836): the row is a detail of the row above it (the early abort reason under + // Optimization), so only its label gets a 12px left indent; its value stays in the same + // column as every other value, as in erikdarlingdata/PerformanceStudio#614. + void AddRow(string label, string value, string? colorKey, bool nested) { grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); @@ -1620,7 +1623,7 @@ void AddRow(string label, string value, string? colorKey) FontSize = 11, Foreground = labelBrush, HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 1, 8, 1) + Margin = new Thickness(nested ? 12 : 0, 1, 8, 1) }; Grid.SetRow(labelText, rowIndex); Grid.SetColumn(labelText, 0); @@ -1641,7 +1644,7 @@ void AddRow(string label, string value, string? colorKey) } foreach (var row in PlanDisplayText.BuildRuntimeSummaryRows(statement)) - AddRow(row.Label, row.Value, row.ColorKey); + AddRow(row.Label, row.Value, row.ColorKey, row.Nested); RuntimeSummaryContent.Children.Add(grid); SetInsightQuiet(RuntimeSummaryTitle, TooltipFgBrush, RuntimeSummaryAccent, isEmpty: false);