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
205 changes: 205 additions & 0 deletions Lite.Tests/OverviewBlockingLaneDataStartLiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor Lite.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using DuckDB.NET.Data;
using PerformanceMonitorLite.Controls;
using PerformanceMonitorLite.Database;
using PerformanceMonitorLite.Services;
using Xunit;

namespace PerformanceMonitorLite.Tests;

/// <summary>
/// The Overview blocking chart's "Showing since" note (#4966) against a real store, through the same step the control runs
/// (<see cref="LiteBlockingLaneDataStart.ShowAsync"/>): the notice, the quiet start, one series answering, a failed probe and a short window.
/// Anchors are fixed so the tests never straddle a day boundary.
/// </summary>
[Collection("server-time-helper")]
public sealed class OverviewBlockingLaneDataStartLiveTests : IDisposable
{
private const int ServerId = 4966;
private const string ServerName = "OverviewBlockingServer";
private static readonly DateTime End = new(2026, 9, 20, 12, 0, 0, DateTimeKind.Unspecified);
private readonly string _tempDir;
private readonly DuckDbInitializer _duckDb;
private long _nextId = 1;

public OverviewBlockingLaneDataStartLiveTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "OverviewBlockingLane_" + Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(_tempDir);
_duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb"));
}

public void Dispose()
{
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); }
catch { /* best-effort cleanup */ }
}

private static string CollectorOf(QueryWindowRelation relation) =>
relation == QueryWindowRelation.BlockedProcessReports ? "blocked_process_report" : "deadlocks";

private async Task SeedLogRunsAsync(QueryWindowRelation relation, DateTime firstUtc, DateTime lastUtc)
{
using var connection = _duckDb.CreateConnection();
await connection.OpenAsync();
using var readLock = _duckDb.AcquireReadLock();
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, rows_collected)
SELECT $1 + row_number() OVER (), $2, $3, $4, g.t, 12, 'SUCCESS', 0
FROM generate_series($5::TIMESTAMP, $6::TIMESTAMP, INTERVAL 30 MINUTE) AS g(t)";
cmd.Parameters.Add(new DuckDBParameter { Value = _nextId });
cmd.Parameters.Add(new DuckDBParameter { Value = ServerId });
cmd.Parameters.Add(new DuckDBParameter { Value = ServerName });
cmd.Parameters.Add(new DuckDBParameter { Value = CollectorOf(relation) });
cmd.Parameters.Add(new DuckDBParameter { Value = firstUtc });
cmd.Parameters.Add(new DuckDBParameter { Value = lastUtc });
_nextId += await cmd.ExecuteNonQueryAsync() + 1;
}

private static string SinceText(DateTime instant) =>
"Showing since " + PerformanceMonitor.Ui.DisplayZone.Format(instant, TimeZoneInfo.Utc, "yyyy-MM-dd HH:mm:ss");

private static TrendPoint Bar(DateTime at, int count = 1) => new() { Time = at, Count = count };

private async Task<(bool Visible, string Text)> NoteAsync(
DateTime startUtc, DateTime endUtc, IEnumerable<TrendPoint> blockingBars, IEnumerable<TrendPoint> deadlockBars,
Func<QueryWindowRelation, Task<DateTime?>>? floorOf = null)
{
var service = new LocalDataService(_duckDb);
floorOf ??= relation => service.GetQueryWindowFloorAsync(relation, ServerId, startUtc, endUtc);
/* The real note step, end to end, on an STA thread: the banner is a WPF object. No sync context there, so the blocking wait is safe. */
return OnStaThread(() =>
{
var banner = new System.Windows.Controls.TextBlock();
LiteBlockingLaneDataStart.ShowAsync(banner, floorOf, startUtc, endUtc, blockingBars, deadlockBars, TimeZoneInfo.Utc).GetAwaiter().GetResult();
return (banner.Visibility == System.Windows.Visibility.Visible, banner.Text);
});
}

/// <summary>Blocking was collected from 2 days back, deadlocks from 3: the one note names the later start, in the chart's zone.</summary>
[Fact]
public async Task Notice_NamesTheLaterOfTheTwoSeriesStarts()
{
await _duckDb.InitializeAsync();
var blockingFrom = End.AddDays(-2);
await SeedLogRunsAsync(QueryWindowRelation.BlockedProcessReports, blockingFrom, End);
await SeedLogRunsAsync(QueryWindowRelation.Deadlocks, End.AddDays(-3), End);

var (visible, text) = await NoteAsync(End.AddDays(-7), End, [], []);

Assert.True(visible);
Assert.Equal(SinceText(blockingFrom), text);
}

/// <summary>Both collectors ran across the whole range and the first bars come late: no note, and a zero bucket changes nothing.</summary>
[Fact]
public async Task QuietStart_BothCollectorsCoveredTheRange_ShowsNoNote()
{
await _duckDb.InitializeAsync();
await SeedLogRunsAsync(QueryWindowRelation.BlockedProcessReports, End.AddDays(-9), End);
await SeedLogRunsAsync(QueryWindowRelation.Deadlocks, End.AddDays(-9), End);

var (visible, text) = await NoteAsync(
End.AddDays(-7), End, [Bar(End.AddDays(-7).AddHours(5)), Bar(End.AddHours(-1))], [Bar(End.AddDays(-7), 0), Bar(End.AddHours(-2))]);

Assert.False(visible);
Assert.Equal(string.Empty, text);
}

/// <summary>One collector covers the range and the other began 2 days ago: the later start is named, not the covered one.</summary>
[Fact]
public async Task OneSeriesCovered_TheOtherStartedLate_NamesTheLateOne()
{
await _duckDb.InitializeAsync();
var lateFrom = End.AddDays(-2);
await SeedLogRunsAsync(QueryWindowRelation.BlockedProcessReports, End.AddDays(-9), End);
await SeedLogRunsAsync(QueryWindowRelation.Deadlocks, lateFrom, End);

var (visible, text) = await NoteAsync(End.AddDays(-7), End, [], []);

Assert.True(visible);
Assert.Equal(SinceText(lateFrom), text);
}

/// <summary>A probe that throws costs only its own series' start; the other still names the note.</summary>
[Fact]
public async Task AFailedProbe_CostsOnlyItsSeries()
{
await _duckDb.InitializeAsync();
var from = End.AddDays(-2);
await SeedLogRunsAsync(QueryWindowRelation.Deadlocks, from, End);
var service = new LocalDataService(_duckDb);

var (visible, text) = await NoteAsync(End.AddDays(-7), End, [], [],
relation => relation == QueryWindowRelation.BlockedProcessReports
? Task.FromException<DateTime?>(new InvalidOperationException("probe failed"))
: service.GetQueryWindowFloorAsync(relation, ServerId, End.AddDays(-7), End));

Assert.True(visible);
Assert.Equal(SinceText(from), text);
}

/// <summary>A failed blocking probe with a late bar names no start; the covered deadlock series starts at the window start, so no note.</summary>
[Fact]
public async Task AFailedProbe_WithALateBar_AndACoveredOtherSeries_ShowsNoNote()
{
await _duckDb.InitializeAsync();
await SeedLogRunsAsync(QueryWindowRelation.Deadlocks, End.AddDays(-9), End);
var service = new LocalDataService(_duckDb);

var (visible, text) = await NoteAsync(End.AddDays(-7), End, [Bar(End.AddHours(-1))], [],
relation => relation == QueryWindowRelation.BlockedProcessReports
? Task.FromException<DateTime?>(new InvalidOperationException("probe failed"))
: service.GetQueryWindowFloorAsync(relation, ServerId, End.AddDays(-7), End));

Assert.False(visible);
Assert.Equal(string.Empty, text);
}

/// <summary>A window of 90 minutes or less starts no probe, even on a store that would earn a note.</summary>
[Fact]
public async Task AShortWindow_StartsNoProbe_AndShowsNoNote()
{
await _duckDb.InitializeAsync();
var probes = 0;

var (visible, _) = await NoteAsync(End.AddMinutes(-90), End, [], [], relation => { probes++; return Task.FromResult<DateTime?>(End); });

Assert.False(visible);
Assert.Equal(0, probes);
}

private static T OnStaThread<T>(Func<T> body)
{
T result = default!;
Exception? error = null;
var thread = new Thread(() =>
{
try { result = body(); }
catch (Exception ex) { error = ex; }
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();

if (error is not null)
{
throw error;
}

return result;
}
}
101 changes: 101 additions & 0 deletions Lite.Tests/OverviewBlockingLaneDataStartPinTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor Lite.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using Xunit;

namespace PerformanceMonitorLite.Tests;

/// <summary>
/// How the Overview's blocking chart is wired to say where its data starts (#4966), read from the source because the control needs a
/// window to run. The choice is in <c>OverviewBlockingLaneDataStartTests</c>, the notice in <c>OverviewBlockingLaneDataStartLiveTests</c>.
/// </summary>
public sealed class OverviewBlockingLaneDataStartPinTests
{
private static string Code() => Read("CorrelatedTimelineLanesControl.xaml.cs");

private static string Xaml() => Read("CorrelatedTimelineLanesControl.xaml");

private static string Step() => Read("LiteBlockingLaneDataStart.cs");

private static string Between(string source, string from, string to)
{
var start = source.IndexOf(from, StringComparison.Ordinal);
Assert.True(start >= 0, from + " was not found");
var end = source.IndexOf(to, start, StringComparison.Ordinal);
Assert.True(end > start, to + " was not found after " + from);
return source[start..end];
}

[Fact]
public void RefreshAsync_RunsTheNoteAfterTheLanesAreDrawn_OutsideTheReadsWhenAll()
{
var refresh = Between(Code(), "public async Task RefreshAsync(", "private async Task ShowBlockingLaneDataStartAsync(");

var whenAll = Between(refresh, "await Task.WhenAll(", ");");
Assert.DoesNotContain("DataStart", whenAll, StringComparison.Ordinal);

var drawn = refresh.IndexOf("UpdateBlockingLane(blockingData", StringComparison.Ordinal);
var ghost = refresh.IndexOf("AddGhostLine(BlockingChart", StringComparison.Ordinal);
var axes = refresh.IndexOf("SyncXAxes(hoursBack", StringComparison.Ordinal);
var note = refresh.IndexOf("await ShowBlockingLaneDataStartAsync(", StringComparison.Ordinal);
Assert.True(drawn > 0 && ghost > drawn && axes > ghost && note > axes, "the lanes, ghost lines and axes come before the note");
Assert.Single(Regex.Matches(refresh, "ShowBlockingLaneDataStartAsync"));
}

[Fact]
public void TheWindow_IsTheOneTheReadsTake_AndTheNoteIsFedFromBothSeriesBars()
{
var refresh = Between(Code(), "public async Task RefreshAsync(", "private async Task ShowBlockingLaneDataStartAsync(");
Assert.Contains("var (windowStartUtc, windowEndUtc) = LocalDataService.GetTimeRange(hoursBack, fromDate, toDate, asOfUtc: null);", refresh, StringComparison.Ordinal);
Assert.Contains("GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate)", refresh, StringComparison.Ordinal);
Assert.Contains("GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate)", refresh, StringComparison.Ordinal);

var call = Between(refresh, "await ShowBlockingLaneDataStartAsync(", ");");
Assert.Contains("windowStartUtc, windowEndUtc", call, StringComparison.Ordinal);
Assert.Contains("blockingTask.Result", call, StringComparison.Ordinal);
Assert.Contains("deadlockTask.Result", call, StringComparison.Ordinal);

var wrapper = Between(Code(), "private async Task ShowBlockingLaneDataStartAsync(", "private void UpdateBlockingLane(");
Assert.Contains("LiteBlockingLaneDataStart.ShowAsync(", wrapper, StringComparison.Ordinal);
Assert.Contains("BlockingLaneDataStartBanner", wrapper, StringComparison.Ordinal);
Assert.Contains("GetQueryWindowFloorAsync(relation, _serverId, startUtc, endUtc)", wrapper, StringComparison.Ordinal);
Assert.Contains("_displayZone()", wrapper, StringComparison.Ordinal);
}

[Fact]
public void TheStep_ProbesBothRelations_ThroughTheShortWindowGuard_AndWordsTheBanner()
{
var step = Between(Step(), "internal static async Task ShowAsync(", "/// <summary>One series' start:");
Assert.DoesNotContain("ProbeWindowFloorOrNullAsync", step, StringComparison.Ordinal);
Assert.True(step.IndexOf("McpQueryTools.CanWindowBeTruncated(startUtc, endUtc)", StringComparison.Ordinal)
< step.IndexOf("Probe(floorOf, QueryWindowRelation.BlockedProcessReports)", StringComparison.Ordinal));
Assert.Contains("Probe(floorOf, QueryWindowRelation.Deadlocks)", step, StringComparison.Ordinal);
Assert.Contains("StartAsync(floorOf, startUtc, endUtc, blockingBars, deadlockBars)", step, StringComparison.Ordinal);
Assert.Contains("ChooseAsync(blockingProbe, deadlockProbe, blockingBars, deadlockBars)", step, StringComparison.Ordinal);
Assert.Contains("ServerTab.ApplyWindowFloorToBanner(banner, start, startUtc, zone)", step, StringComparison.Ordinal);
Assert.Contains("bar.Count > 0", Step(), StringComparison.Ordinal);
}

[Fact]
public void TheBanner_IsDeclaredInAnAutoRowAboveTheChart_AndOnlyOnTheBlockingLane()
{
var chart = Between(Xaml(), "<!-- Lane 3: Blocking -->", "<!-- Lane 4");
Assert.Contains("<RowDefinition Height=\"Auto\"/>", chart, StringComparison.Ordinal);
Assert.Matches(@"x:Name=""BlockingLaneDataStartBanner""\s+Visibility=""Collapsed""", chart);
Assert.Contains("Foreground=\"{DynamicResource ForegroundBrush}\"", chart, StringComparison.Ordinal);
Assert.Contains("<ScottPlot:WpfPlot Grid.Row=\"1\" Grid.Column=\"1\" x:Name=\"BlockingChart\"/>", chart, StringComparison.Ordinal);
Assert.Single(Regex.Matches(Xaml(), "DataStartBanner"));
}

private static string Read(string name, [CallerFilePath] string thisFile = "") =>
File.ReadAllText(Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "Lite", "Controls", name))).ReplaceLineEndings("\n");
}
Loading
Loading