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
71 changes: 71 additions & 0 deletions BuildMonitor.Test/RunProgressTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.BuildMonitor.Test;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests for <see cref="BuildMonitor.MeasureRun"/>.
/// </summary>
/// <remarks>
/// A pending run is queued rather than building, so the time since it was queued must not show up as
/// progress or shorten its ETA.
/// </remarks>
[TestClass]
[DoNotParallelize]
public sealed class RunProgressTests
{
private static readonly DateTimeOffset Now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
private static readonly TimeSpan Estimate = TimeSpan.FromMinutes(4);

[TestInitialize]
public void FreezeClock() => Clock.UtcNowOverride = () => Now;

[TestCleanup]
public void ReleaseClock() => Clock.UtcNowOverride = null;

[TestMethod]
public void APendingRunShowsNoProgressAndTheWholeEstimateAsItsEta()
{
// Arrange
Run run = new() { Status = RunStatus.Pending, Started = Now.AddSeconds(-40) };

// Act
(TimeSpan duration, TimeSpan eta, double progress) = BuildMonitor.MeasureRun(run, Estimate);

// Assert
Assert.AreEqual(TimeSpan.Zero, duration);
Assert.AreEqual(Estimate, eta);
Assert.AreEqual(0, progress);
}

[TestMethod]
public void ARunningRunCountsTheTimeSinceItStarted()
{
// Arrange
Run run = new() { Status = RunStatus.Running, Started = Now.AddMinutes(-1) };

// Act
(TimeSpan duration, TimeSpan eta, double progress) = BuildMonitor.MeasureRun(run, Estimate);

// Assert
Assert.AreEqual(TimeSpan.FromMinutes(1), duration);
Assert.AreEqual(TimeSpan.FromMinutes(3), eta);
Assert.AreEqual(0.25, progress, 1e-9);
}

[TestMethod]
public void AFinishedRunUsesItsRecordedDuration()
{
// Arrange
Run run = new() { Status = RunStatus.Success, Started = Now.AddHours(-1), LastUpdated = Now.AddHours(-1).AddMinutes(2) };

// Act
(TimeSpan duration, TimeSpan eta, double progress) = BuildMonitor.MeasureRun(run, Estimate);

// Assert
Assert.AreEqual(TimeSpan.FromMinutes(2), duration);
Assert.AreEqual(TimeSpan.FromMinutes(2), eta);
Assert.AreEqual(0.5, progress, 1e-9);
}
}
22 changes: 19 additions & 3 deletions BuildMonitor/BuildMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@
/// fields actually occupy tests the bug itself, so a fixed binding is recognised as fixed and
/// anything else is refused rather than guessed at.
/// </remarks>
private static unsafe int? ProbeNativeImGuiTableColumnSize()

Check warning on line 236 in BuildMonitor/BuildMonitor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Avoid using this unsafe code block.

Check warning on line 236 in BuildMonitor/BuildMonitor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Avoid using this unsafe code block.
{
int csharpSize = sizeof(ImGuiTableColumn);
int widthGivenOffset;
Expand Down Expand Up @@ -332,7 +332,7 @@
return narrowFieldBytes == NarrowlyBoundColumnFields.Length * 2 ? csharpSize : null;
}

private static unsafe void SaveColumnWidth(string columnName, int columnIndex)

Check warning on line 335 in BuildMonitor/BuildMonitor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Avoid using this unsafe code block.

Check warning on line 335 in BuildMonitor/BuildMonitor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Avoid using this unsafe code block.
{
// Null means the layout probe rejected the binding, so there is no address we can trust.
if (NativeImGuiTableColumnSize is not int nativeStructSize)
Expand Down Expand Up @@ -459,7 +459,7 @@
SaveSettingsIfRequired();
}

private static void UpdateOwnerTabs()

Check warning on line 462 in BuildMonitor/BuildMonitor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

Check warning on line 462 in BuildMonitor/BuildMonitor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
{
// Build expected tabs based on provider type:
// - GitHub: one tab per owner (org/user)
Expand All @@ -468,7 +468,7 @@
HashSet<string> expectedTabIds = [AllOwnersTabId, LogsTabId];
Dictionary<string, (string Label, Action Content)> tabsToCreate = [];

foreach ((BuildProviderName providerName, BuildProvider provider) in AppData.BuildProviders)

Check warning on line 471 in BuildMonitor/BuildMonitor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove the unused local variable 'providerName'.

Check warning on line 471 in BuildMonitor/BuildMonitor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove the unused local variable 'providerName'.
{
if (provider is GitHub)
{
Expand Down Expand Up @@ -675,7 +675,7 @@
return displayName;
}

private static string MakeRepositoryDisplayName(Build build)

Check warning on line 678 in BuildMonitor/BuildMonitor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'MakeRepositoryDisplayName' method overloads should be adjacent.

Check warning on line 678 in BuildMonitor/BuildMonitor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'MakeRepositoryDisplayName' method overloads should be adjacent.
{
string repoName = build.Repository.Name;
string ownerName = build.Owner.Name;
Expand Down Expand Up @@ -887,9 +887,7 @@
bool isOngoing = latestRun.IsOngoing;
// Use branch-specific estimation for more accurate estimates
TimeSpan estimate = build.CalculateEstimatedDuration(branch);
TimeSpan duration = isOngoing ? Clock.UtcNow - latestRun.Started : latestRun.Duration;
TimeSpan eta = duration < estimate ? estimate - duration : TimeSpan.Zero;
double progress = estimate > TimeSpan.Zero ? duration.TotalSeconds / estimate.TotalSeconds : 0;
(TimeSpan duration, TimeSpan eta, double progress) = MeasureRun(latestRun, estimate);

bool shouldOpenContextMenu = false;

Expand All @@ -912,6 +910,24 @@
return shouldOpenContextMenu;
}

/// <summary>
/// How long a run has taken, how long it has left and how far through it is, against an estimate.
/// A pending run is still queued and has not started building, so none of the time since
/// <see cref="Run.Started"/> counts: it shows no progress and the whole estimate as its ETA.
/// </summary>
internal static (TimeSpan Duration, TimeSpan Eta, double Progress) MeasureRun(Run run, TimeSpan estimate)
{
TimeSpan duration = run.Status switch
{
RunStatus.Pending => TimeSpan.Zero,
RunStatus.Running => Clock.UtcNow - run.Started,
_ => run.Duration,
};
TimeSpan eta = duration < estimate ? estimate - duration : TimeSpan.Zero;
double progress = estimate > TimeSpan.Zero ? duration.TotalSeconds / estimate.TotalSeconds : 0;
return (duration, eta, progress);
}

private static bool RenderStatusColumn(Build build, Run latestRun, bool isOngoing, double progress)
{
bool shouldOpenContextMenu = false;
Expand Down
Loading