diff --git a/BuildMonitor.Test/RunProgressTests.cs b/BuildMonitor.Test/RunProgressTests.cs
new file mode 100644
index 0000000..97c2773
--- /dev/null
+++ b/BuildMonitor.Test/RunProgressTests.cs
@@ -0,0 +1,71 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.BuildMonitor.Test;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests for .
+///
+///
+/// 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.
+///
+[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);
+ }
+}
diff --git a/BuildMonitor/BuildMonitor.cs b/BuildMonitor/BuildMonitor.cs
index adc05de..ed70adf 100644
--- a/BuildMonitor/BuildMonitor.cs
+++ b/BuildMonitor/BuildMonitor.cs
@@ -887,9 +887,7 @@ private static bool RenderBuildBranchRowColumns(Build build, BranchName branch,
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;
@@ -912,6 +910,24 @@ private static bool RenderBuildBranchRowColumns(Build build, BranchName branch,
return shouldOpenContextMenu;
}
+ ///
+ /// 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
+ /// counts: it shows no progress and the whole estimate as its ETA.
+ ///
+ 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;