Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions Darling/Darling.Tests/Viewer4574Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/*
* 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.Collections.Generic;
using PerformanceMonitor.PlanAnalysis;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #4574: <see cref="PropertyRows"/> pulls the properties panel's row model, filter matching and
/// "Copy all properties" text out of <c>PlanViewerControl.Properties.cs</c> (WPF, can't run in a
/// unit test on macOS) so this suite can pin them directly. New type, so every assertion here is
/// a compile-only RED against dev: <c>PropertyRows</c> doesn't exist on dev.
/// </summary>
public sealed class Viewer4574Tests
{
private static PropertySection Section(string title, params PropertyRow[] rows)
{
var section = new PropertySection { Title = title };
section.Rows.AddRange(rows);
return section;
}

[Fact]
public void SectionTitleMatches_EmptyFilter_AlwaysTrue()
{
Assert.True(PropertyRows.SectionTitleMatches("General", ""));
}

[Fact]
public void SectionTitleMatches_IsCaseInsensitive()
{
Assert.True(PropertyRows.SectionTitleMatches("General", "GENERAL"));
Assert.True(PropertyRows.SectionTitleMatches("General", "general"));
}

[Fact]
public void SectionTitleMatches_NoMatch_False()
{
Assert.False(PropertyRows.SectionTitleMatches("General", "zzz"));
}

[Fact]
public void RowMatches_SectionTitleMatch_KeepsEveryRowRegardlessOfOwnText()
{
var row = new PropertyRow { Label = "Node ID", Value = "3", SearchText = "Node ID 3" };

// Filter text "general" matches the section title, not this row's own text.
Assert.True(PropertyRows.RowMatches(row, "general", sectionTitleMatches: true));
}

[Fact]
public void RowMatches_OwnTextMatch_CaseInsensitiveOnLabelAndValue()
{
var labelRow = new PropertyRow { Label = "Physical Operation", Value = "Hash Match", SearchText = "Physical Operation Hash Match" };
var valueRow = new PropertyRow { Label = "Ordered", Value = "False", SearchText = "Ordered False" };

Assert.True(PropertyRows.RowMatches(labelRow, "PHYSICAL", sectionTitleMatches: false));
Assert.True(PropertyRows.RowMatches(valueRow, "false", sectionTitleMatches: false));
}

[Fact]
public void RowMatches_NoMatch_False()
{
var row = new PropertyRow { Label = "Ordered", Value = "False", SearchText = "Ordered False" };

Assert.False(PropertyRows.RowMatches(row, "zzz", sectionTitleMatches: false));
}

[Fact]
public void SectionVisible_NoVisibleRows_False()
{
Assert.False(PropertyRows.SectionVisible(0));
}

[Fact]
public void SectionVisible_AtLeastOneVisibleRow_True()
{
Assert.True(PropertyRows.SectionVisible(1));
}

[Fact]
public void ClampWidth_BelowMin_ClampsUp()
{
Assert.Equal(PropertyRows.MinPropertiesWidth, PropertyRows.ClampWidth(50));
}

[Fact]
public void ClampWidth_AboveMax_ClampsDown()
{
Assert.Equal(PropertyRows.MaxPropertiesWidth, PropertyRows.ClampWidth(5000));
}

[Fact]
public void ClampWidth_WithinBounds_Unchanged()
{
Assert.Equal(400, PropertyRows.ClampWidth(400));
}

[Fact]
public void CopyValue_OrdinaryRow_ReturnsValue()
{
var row = new PropertyRow { Label = "Node ID", Value = "3" };

Assert.Equal("3", row.CopyValue);
}

[Fact]
public void CopyLabelAndValue_OrdinaryRow_FormatsLabelColonValue()
{
var row = new PropertyRow { Label = "Node ID", Value = "3" };

Assert.Equal("Node ID: 3", row.CopyLabelAndValue);
}

[Fact]
public void CopyValue_BlockRow_ReturnsBlockTextNotValue()
{
var row = new PropertyRow { Label = "Thread 1", Value = "ignored", BlockText = "Thread 1: 500 ms" };

Assert.Equal("Thread 1: 500 ms", row.CopyValue);
Assert.Equal("Thread 1: 500 ms", row.CopyLabelAndValue);
}

[Fact]
public void BuildPropertiesText_OrdinaryRow_FormatsAsIndentedLabelColonValue()
{
var sections = new List<PropertySection>
{
Section("General",
new PropertyRow { Label = "Physical Operation", Value = "Hash Match" })
};

var text = PropertyRows.BuildPropertiesText("Hash Match", "Node ID: 3", sections);

Assert.Equal(
"Hash Match\nNode ID: 3\n\nGeneral\n Physical Operation: Hash Match",
text.Replace("\r\n", "\n"));
}

[Fact]
public void BuildPropertiesText_CodeRow_PutsValueOnItsOwnLineVerbatim()
{
var sections = new List<PropertySection>
{
Section("Object",
new PropertyRow { Label = "Full Name", Value = "[Db].[dbo].[Table]", IsCode = true })
};

var text = PropertyRows.BuildPropertiesText("Clustered Index Scan", "", sections);

Assert.Equal(
"Clustered Index Scan\n\nObject\n Full Name:\n[Db].[dbo].[Table]",
text.Replace("\r\n", "\n"));
}

[Fact]
public void BuildPropertiesText_BlockRow_EmitsBlockTextVerbatim()
{
var sections = new List<PropertySection>
{
Section("Actual CPU",
new PropertyRow { BlockText = "CPU\n Thread 0: 10 ms\n Thread 1: 20 ms" })
};

var text = PropertyRows.BuildPropertiesText("Hash Match", "", sections);

Assert.Equal(
"Hash Match\n\nActual CPU\nCPU\n Thread 0: 10 ms\n Thread 1: 20 ms",
text.Replace("\r\n", "\n"));
}

[Fact]
public void BuildPropertiesText_SectionWithNoRows_IsSkipped()
{
var sections = new List<PropertySection>
{
Section("Empty"),
Section("General", new PropertyRow { Label = "Node ID", Value = "3" })
};

var text = PropertyRows.BuildPropertiesText("Hash Match", "", sections);

Assert.DoesNotContain("Empty", text);
Assert.Contains("General", text);
}
}
141 changes: 141 additions & 0 deletions PerformanceMonitor.PlanAnalysis/PropertyRows.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* 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.Collections.Generic;
using System.Text;

namespace PerformanceMonitor.PlanAnalysis;

/// <summary>
/// One row of the properties panel: what it says, and what the filter box and the copy menu work
/// from. The viewer's panel is raw WPF controls with no bindings behind them, so once a row is in
/// the visual tree its text is the only thing left to read back, which is why this is recorded
/// separately while the panel builds rather than reconstructed later. Kept here (not in
/// <c>PerformanceMonitor.Ui</c>, which is net10.0-windows-only) so a unit test can pin the filter
/// match and the copy text without needing WPF.
/// </summary>
public sealed class PropertyRow
{
public string Label { get; init; } = "";
public string Value { get; init; } = "";
public bool IsCode { get; init; }

/// <summary>
/// Plain text for rows that are not a label/value pair (the per-thread breakdown, a warning
/// entry). Null for ordinary rows, which the copy menu renders from <see cref="Label"/> and
/// <see cref="Value"/>.
/// </summary>
public string? BlockText { get; init; }

/// <summary>What the filter box matches against, case-insensitively.</summary>
public string SearchText { get; init; } = "";

/// <summary>What "Copy value" hands back.</summary>
public string CopyValue => BlockText ?? Value;

/// <summary>What "Copy name and value" hands back.</summary>
public string CopyLabelAndValue => BlockText
?? (string.IsNullOrEmpty(Label) ? Value : $"{Label}: {Value}");
}

/// <summary>One section of the properties panel: a title and the rows filed under it.</summary>
public sealed class PropertySection
{
public string Title { get; init; } = "";
public List<PropertyRow> Rows { get; } = new();
}

/// <summary>
/// Pure filter-match and copy-text logic for the properties panel, shared by the Darling Viewer
/// and Lite (both host <c>PerformanceMonitor.Ui.PlanViewerControl</c>). Mirrors
/// PerformanceStudio's Avalonia properties panel: same filter behaviour (case-insensitive on
/// label+value, a section whose title matches keeps every row, an empty section always hides),
/// same "Copy all properties" text layout, same width default/clamp.
/// </summary>
public static class PropertyRows
{
/// <summary>The width the panel opens to when nothing has been remembered yet.</summary>
public const double DefaultPropertiesWidth = 380;

/// <summary>The narrowest the panel can be dragged to while open.</summary>
public const double MinPropertiesWidth = 280;

/// <summary>The widest the panel can be dragged to while open.</summary>
public const double MaxPropertiesWidth = 800;

/// <summary>Clamps a remembered/dragged width to the panel's open-state bounds.</summary>
public static double ClampWidth(double width) =>
Math.Clamp(width, MinPropertiesWidth, MaxPropertiesWidth);

/// <summary>
/// True when a section's own title matches the filter (case-insensitive), which is what lets
/// typing a section name jump to that section rather than empty it: every row under a
/// title-matching section stays visible regardless of its own text.
/// </summary>
public static bool SectionTitleMatches(string sectionTitle, string filter) =>
filter.Length == 0 || sectionTitle.Contains(filter, StringComparison.OrdinalIgnoreCase);

/// <summary>
/// True when a row should stay visible: either its section's title already matched (see
/// <see cref="SectionTitleMatches"/>), or the row's own label+value text does.
/// </summary>
public static bool RowMatches(PropertyRow row, string filter, bool sectionTitleMatches) =>
sectionTitleMatches || row.SearchText.Contains(filter, StringComparison.OrdinalIgnoreCase);

/// <summary>
/// True when a section with the given visible-row count should itself stay visible. An
/// empty section (no rows at all, filtered or not) is always hidden — a few sections build
/// with zero rows depending on the node, and an expander with nothing inside it is noise
/// whether or not anyone is filtering.
/// </summary>
public static bool SectionVisible(int visibleRowCount) => visibleRowCount > 0;

/// <summary>
/// The whole panel as plain text, for "Copy all properties": the operator header, then a
/// line per section title with " Label: Value" beneath it. Code values and block rows
/// (the per-thread breakdown, a warning entry) go out verbatim on their own lines, so a
/// predicate or a CREATE INDEX statement comes back pasteable rather than needing to be
/// re-indented first. Sections with no rows are skipped.
/// </summary>
public static string BuildPropertiesText(
string header, string subHeader, IReadOnlyList<PropertySection> sections)
{
var text = new StringBuilder();
text.AppendLine(header);
if (!string.IsNullOrEmpty(subHeader))
text.AppendLine(subHeader);

foreach (var section in sections)
{
if (section.Rows.Count == 0) continue;

text.AppendLine();
text.AppendLine(section.Title);
foreach (var row in section.Rows)
{
if (row.BlockText != null)
{
text.AppendLine(row.BlockText);
}
else if (row.IsCode)
{
if (!string.IsNullOrEmpty(row.Label))
text.Append(" ").Append(row.Label).AppendLine(":");
text.AppendLine(row.Value);
}
else
{
text.Append(" ").Append(row.Label).Append(": ").AppendLine(row.Value);
}
}
}

return text.ToString().TrimEnd();
}
}
Loading
Loading