diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs b/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs index cfa3812..bbb482d 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs @@ -30,7 +30,8 @@ private void ShowRuntimeSummary(PlanStatement statement) }; int rowIndex = 0; - void AddRow(string label, string value, string? brushKey = null) + // nested: the row is a detail of the row above it, so its label is indented under that row's. + void AddRow(string label, string value, string? brushKey = null, bool nested = false) { grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); @@ -40,7 +41,7 @@ void AddRow(string label, string value, string? brushKey = null) 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); @@ -84,7 +85,8 @@ static string MemoryGrantBrushKey(double pctUsed, bool hasSpill) var hasSpillInTree = statement.RootNode != null && HasSpillInPlanTree(statement.RootNode); - // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost. + // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → CE Model → Optimization → Cost. + // #613 moved CE Model above Optimization, so Optimization and its nested early abort reason end the list. // Extra Avalonia-only rows (threads, UDF, cached plan size) kept near their logical neighbors. if (statement.QueryTimeStats != null) @@ -188,13 +190,15 @@ static string MemoryGrantBrushKey(double pctUsed, bool hasSpill) } } - // Optimization + CE model - if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) - AddRow("Optimization", statement.StatementOptmLevel); - if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) - AddRow("Early abort", statement.StatementOptmEarlyAbortReason); + // CE model, then Optimization. #613: the early abort reason is part of the optimization + // result, not a fact of its own, so it sits under the Optimization row. if (statement.CardinalityEstimationModelVersion > 0) AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); + var hasOptimizationRow = !string.IsNullOrEmpty(statement.StatementOptmLevel); + if (hasOptimizationRow) + AddRow("Optimization", statement.StatementOptmLevel!); + if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) + AddRow("Early abort", statement.StatementOptmEarlyAbortReason, nested: hasOptimizationRow); if (grid.Children.Count > 0) { diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index 4aad7cc..137ba3b 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -308,8 +308,9 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) sb.AppendLine("
"); // Order per Joe (#215 E11): Elapsed → CPU:Elapsed → DOP → CPU → Compile → - // Memory → Used → Optimization → CE Model → Cost. Puts the important - // measurements on top and groups related metrics together. + // Memory → Used → CE Model → Optimization → Cost. Puts the important + // measurements on top and groups related metrics together. #613 moved CE Model + // above Optimization, the same order as the App's Runtime Summary. if (stmt.QueryTime != null) { WriteRow(sb, "Elapsed", $"{stmt.QueryTime.ElapsedTimeMs:N0} ms"); @@ -343,10 +344,10 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) if (stmt.MemoryGrant.SerialRequiredKB > 0 && stmt.MemoryGrant.SerialRequiredKB != stmt.MemoryGrant.DesiredKB) WriteRow(sb, "Serial required", FormatKB(stmt.MemoryGrant.SerialRequiredKB)); } - if (stmt.OptimizationLevel != null) - WriteRow(sb, "Optimization", Encode(stmt.OptimizationLevel)); if (stmt.CardinalityEstimationModel > 0) WriteRow(sb, "CE Model", stmt.CardinalityEstimationModel.ToString()); + if (stmt.OptimizationLevel != null) + WriteRow(sb, "Optimization", Encode(stmt.OptimizationLevel)); WriteRow(sb, "Cost", stmt.EstimatedCost.ToString("N2")); sb.AppendLine("
"); sb.AppendLine(""); diff --git a/src/PlanViewer.Web/Pages/InsightsPanel.razor b/src/PlanViewer.Web/Pages/InsightsPanel.razor index 4ccdfcc..836adc1 100644 --- a/src/PlanViewer.Web/Pages/InsightsPanel.razor +++ b/src/PlanViewer.Web/Pages/InsightsPanel.razor @@ -8,7 +8,8 @@

@(isEstimatedRuntime ? "Predicted Runtime" : "Runtime")

- @* Order per Joe #215 E11: Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost *@ + @* Order per Joe #215 E11: Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → CE Model → Optimization → Cost. + #613 moved CE Model above Optimization, the same order as the App's Runtime Summary. *@ @if (Stmt.QueryTime != null) {
@@ -86,18 +87,18 @@
} } - @if (Stmt.OptimizationLevel != null) + @if (Stmt.CardinalityEstimationModel > 0) {
- Optimization - @Stmt.OptimizationLevel + CE Model + @Stmt.CardinalityEstimationModel
} - @if (Stmt.CardinalityEstimationModel > 0) + @if (Stmt.OptimizationLevel != null) {
- CE Model - @Stmt.CardinalityEstimationModel + Optimization + @Stmt.OptimizationLevel
}
diff --git a/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs b/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs index f316bdb..b4734ac 100644 --- a/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs +++ b/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs @@ -115,6 +115,24 @@ public void Export_CraftedSeverityOnAnOperator_IsMappedToo() Assert.Contains("
", html); } + /// #613: the runtime card lists CE Model above Optimization, the same order as the App's Runtime Summary. + [Fact] + public void Export_RuntimeCard_ListsCeModelAboveOptimization() + { + var plan = PlanTestHelper.LoadAndAnalyze("key_lookup_plan.sqlplan"); + foreach (var batch in plan.Batches) + foreach (var stmt in batch.Statements) + PlanLayoutEngine.Layout(stmt); + + var result = ResultMapper.Map(plan, "key_lookup_plan.sqlplan"); + var html = HtmlExporter.Export(result, TextFormatter.Format(result)); + + var ceModel = html.IndexOf("CE Model", StringComparison.Ordinal); + var optimization = html.IndexOf("Optimization", StringComparison.Ordinal); + Assert.True(ceModel >= 0, "no CE Model row"); + Assert.True(optimization > ceModel, "Optimization is not below CE Model"); + } + private static string ExportWithSeverity(string? severity, bool onOperator = false) { var warning = new WarningResult { Severity = severity!, Type = "demo", Message = "demo" }; diff --git a/tests/PlanViewer.Core.Tests/RuntimeSummaryEarlyAbortTests.cs b/tests/PlanViewer.Core.Tests/RuntimeSummaryEarlyAbortTests.cs new file mode 100644 index 0000000..d9721de --- /dev/null +++ b/tests/PlanViewer.Core.Tests/RuntimeSummaryEarlyAbortTests.cs @@ -0,0 +1,94 @@ +using System.IO; +using System.Linq; +using Avalonia.Controls; +using Avalonia.LogicalTree; +using PlanViewer.App.Controls; + +namespace PlanViewer.Core.Tests; + +/// +/// #613: the early abort reason is part of the optimization result (StatementOptmEarlyAbortReason +/// only exists for a FULL optimization), so the Runtime Summary shows it as a detail of the +/// Optimization row: on the next row, with its label indented and its value in the same column. +/// CE model moved up above Optimization, so Optimization and its reason end the list. +/// +public class RuntimeSummaryEarlyAbortTests +{ + [Fact] + public void EarlyAbort_IsNestedUnderOptimization() + { + HeadlessUi.Run(() => + { + var viewer = LoadPlan(stripOptimizationLevel: false); + var window = new Window { Content = viewer, Width = 1400, Height = 900 }; + window.Show(); + window.UpdateLayout(); + + var grid = SummaryGrid(viewer); + var ceModel = Label(grid, "CE model"); + var optimization = Label(grid, "Optimization"); + var earlyAbort = Label(grid, "Early abort"); + + Assert.Equal(Grid.GetRow(ceModel) + 1, Grid.GetRow(optimization)); + Assert.Equal(Grid.GetRow(optimization) + 1, Grid.GetRow(earlyAbort)); + Assert.Equal(grid.RowDefinitions.Count - 1, Grid.GetRow(earlyAbort)); + Assert.True(earlyAbort.Margin.Left > optimization.Margin.Left, + $"Early abort label is not indented: {earlyAbort.Margin.Left} vs {optimization.Margin.Left}"); + Assert.Equal("GoodEnoughPlanFound", Value(grid, earlyAbort)); + }); + } + + [Fact] + public void EarlyAbort_WithoutAnOptimizationRow_IsNotIndented() + { + HeadlessUi.Run(() => + { + var viewer = LoadPlan(stripOptimizationLevel: true); + var window = new Window { Content = viewer, Width = 1400, Height = 900 }; + window.Show(); + window.UpdateLayout(); + + var grid = SummaryGrid(viewer); + Assert.DoesNotContain(grid.Children.OfType(), t => t.Text == "Optimization"); + + var earlyAbort = Label(grid, "Early abort"); + var elapsed = Label(grid, "Elapsed"); + Assert.Equal(elapsed.Margin.Left, earlyAbort.Margin.Left); + }); + } + + /// + /// key_lookup_plan.sqlplan is an actual plan with StatementOptmLevel="FULL" and + /// StatementOptmEarlyAbortReason="GoodEnoughPlanFound". Removing the level leaves a reason + /// with no row to nest under. + /// + private static PlanViewerControl LoadPlan(bool stripOptimizationLevel) + { + var path = Path.Combine("Plans", "key_lookup_plan.sqlplan"); + Assert.True(File.Exists(path), $"Test plan not found: {path}"); + var xml = File.ReadAllText(path).Replace("encoding=\"utf-16\"", "encoding=\"utf-8\""); + + const string level = "StatementOptmLevel=\"FULL\""; + Assert.Contains(level, xml); + Assert.Contains("StatementOptmEarlyAbortReason=\"GoodEnoughPlanFound\"", xml); + if (stripOptimizationLevel) + xml = xml.Replace(level, ""); + + var viewer = new PlanViewerControl(); + Assert.True(viewer.LoadPlan(xml, "key_lookup_plan.sqlplan"), $"Plan failed to load: {viewer.LastLoadError}"); + return viewer; + } + + private static Grid SummaryGrid(PlanViewerControl viewer) + { + var panel = viewer.GetLogicalDescendants().OfType().First(p => p.Name == "RuntimeSummaryContent"); + return (Grid)panel.Children.Single(); + } + + private static TextBlock Label(Grid grid, string label) + => grid.Children.OfType().Single(t => Grid.GetColumn(t) == 0 && t.Text == label); + + private static string Value(Grid grid, TextBlock label) + => grid.Children.OfType() + .Single(t => Grid.GetColumn(t) == 1 && Grid.GetRow(t) == Grid.GetRow(label)).Text ?? ""; +}