From cb367ce82bc1664078465a9391b5b2dbd5e2d880 Mon Sep 17 00:00:00 2001
From: c <85012225+Cynrath@users.noreply.github.com>
Date: Fri, 18 Sep 2026 10:42:12 +0300
Subject: [PATCH 1/5] operation: add manager-neutral progress state
Introduce OperationProgress (stage, nullable percentage, byte counters,
measured throughput) and side-car ReportProgress/ResetProgress plumbing on
AbstractOperation with a single injected clock. Reporting never logs and
never touches execution, retries, or history.
---
.../OperationProgress.cs | 119 +++++++++++++
.../AbstractOperation_Progress.cs | 167 ++++++++++++++++++
2 files changed, 286 insertions(+)
create mode 100644 src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
create mode 100644 src/UniGetUI.PackageEngine.Operations/AbstractOperation_Progress.cs
diff --git a/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs b/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
new file mode 100644
index 0000000000..3c9980cc39
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
@@ -0,0 +1,119 @@
+namespace UniGetUI.PackageEngine.Enums
+{
+ ///
+ /// The phase a package operation is currently in. Manager-neutral: no WinGet, COM,
+ /// or CLI-specific concepts leak into this model.
+ ///
+ public enum OperationProgressStage
+ {
+ Unknown,
+ Downloading,
+ Installing,
+ Updating,
+ Uninstalling,
+ }
+
+ ///
+ /// Manager-neutral snapshot of package-operation progress.
+ ///
+ /// Unknown percentage is represented as a null (never zero):
+ /// the UI must render null as indeterminate and a known value as determinate.
+ /// Download and install phases are never merged into a synthetic overall total.
+ /// Instances are immutable; the operation layer enriches download reports with a
+ /// measured downstream.
+ ///
+ public sealed record OperationProgress(
+ OperationProgressStage Stage,
+ double? Percentage,
+ ulong? BytesDownloaded,
+ ulong? BytesTotal,
+ double? BytesPerSecond
+ )
+ {
+ /// Plain unknown: indeterminate UI, log-driven status text is kept.
+ public static readonly OperationProgress Unknown = new(
+ OperationProgressStage.Unknown,
+ null,
+ null,
+ null,
+ null
+ );
+
+ /// Unknown progress within a known stage (e.g. installing with no counters).
+ public static OperationProgress ForStage(OperationProgressStage stage) =>
+ new(stage, null, null, null, null);
+
+ ///
+ /// Download progress from real cumulative byte counters. Percentage is derived as
+ /// downloaded / total and clamped to 100 when counters overshoot; a zero
+ /// total yields indeterminate (unknown) progress rather than a fake zero.
+ ///
+ public static OperationProgress FromDownload(ulong downloaded, ulong total) =>
+ total == 0
+ ? new(OperationProgressStage.Downloading, null, downloaded, null, null)
+ : new(
+ OperationProgressStage.Downloading,
+ Math.Min(downloaded * 100.0 / total, 100.0),
+ downloaded,
+ total,
+ null
+ );
+
+ ///
+ /// Install progress from an explicitly reported percentage. Null, non-finite, or
+ /// negative values mean the installer supplied no usable progress and map to
+ /// indeterminate rather than a fake number.
+ ///
+ public static OperationProgress FromInstall(double? percent) =>
+ FromStagePercent(OperationProgressStage.Installing, percent);
+
+ ///
+ /// Update progress from an explicitly reported percentage. Same unknown semantics
+ /// as .
+ ///
+ public static OperationProgress FromUpdate(double? percent) =>
+ FromStagePercent(OperationProgressStage.Updating, percent);
+
+ ///
+ /// Uninstall progress from an explicitly reported percentage. Same unknown semantics
+ /// as .
+ ///
+ public static OperationProgress FromUninstall(double? percent) =>
+ FromStagePercent(OperationProgressStage.Uninstalling, percent);
+
+ private static OperationProgress FromStagePercent(
+ OperationProgressStage stage,
+ double? percent
+ ) =>
+ percent is { } value
+ && !double.IsNaN(value)
+ && !double.IsInfinity(value)
+ && value >= 0
+ ? new(stage, Math.Min(value, 100.0), null, null, null)
+ : new(stage, null, null, null, null);
+
+ ///
+ /// True only for a real, finite percentage. Unknown progress is never zero.
+ ///
+ public bool IsDeterminate =>
+ Percentage is { } value
+ && !double.IsNaN(value)
+ && !double.IsInfinity(value)
+ && value >= 0;
+
+ /// True when a usable measured throughput is attached.
+ public bool HasThroughput => NormalizeBytesPerSecond(BytesPerSecond) is not null;
+
+ ///
+ /// Accepts only positive finite speeds. NaN, Infinity, zero, negatives, and null
+ /// are rejected so they can never be displayed or averaged.
+ ///
+ public static double? NormalizeBytesPerSecond(double? bytesPerSecond) =>
+ bytesPerSecond is { } value
+ && !double.IsNaN(value)
+ && !double.IsInfinity(value)
+ && value > 0
+ ? value
+ : null;
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Operations/AbstractOperation_Progress.cs b/src/UniGetUI.PackageEngine.Operations/AbstractOperation_Progress.cs
new file mode 100644
index 0000000000..aa52d0c3d7
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Operations/AbstractOperation_Progress.cs
@@ -0,0 +1,167 @@
+using UniGetUI.PackageEngine.Enums;
+
+namespace UniGetUI.PackageOperations;
+
+// Progress reporting for AbstractOperation: structured, manager-neutral OperationProgress
+// snapshots flow side-car via ProgressChanged and never touch the log/history path.
+//
+// DESIGN NOTES (maintainer feedback on #5390):
+// - Structured progress callbacks and Line() logging are fully separated. ReportProgress
+// never logs; existing CLI output, return codes, retry decisions, and history entries
+// are unaffected by progress reporting.
+// - ONE clock source: _utcNowProvider (default DateTime.UtcNow, injectable for tests) is
+// the only time source used by progress logic. There is no time-based UI throttling,
+// so there is no second clock to drift.
+// - Speed is measured generically as deltaBytes / deltaTime from real cumulative byte
+// samples. First sample, rewound counters, stalled counters, zero elapsed time, stage
+// changes, and unknown progress never synthesize a speed. Light deterministic EMA
+// smoothing (alpha 0.3) damps per-chunk network jitter.
+// - No ETA is computed.
+public abstract partial class AbstractOperation
+{
+ ///
+ /// Raised on the reporting thread whenever structured progress is reported or reset.
+ /// UI subscribers must marshal to the UI thread (as with LogLineAdded/StatusChanged).
+ /// Never raised from the log path; progress lines in the log do not raise this.
+ ///
+ public event EventHandler? ProgressChanged;
+
+ /// Latest structured progress snapshot. Unknown until first reported.
+ public OperationProgress CurrentProgress { get; private set; } = OperationProgress.Unknown;
+
+ private readonly object ProgressLock = new();
+ private Func UtcNowProvider = static () => DateTime.UtcNow;
+
+ // Throughput tracker state. All fields are guarded by ProgressLock.
+ private bool HasThroughputBaseline;
+ private ulong LastThroughputBytes;
+ private DateTime LastThroughputSampleUtc;
+ private double? SmoothedBytesPerSecond;
+
+ private const double ThroughputSmoothingAlpha = 0.3;
+
+ ///
+ /// Test hook: replaces the single clock used by progress logic. Resets tracker state
+ /// so samples from different clocks are never mixed.
+ ///
+ internal void SetUtcNowProviderForTests(Func provider)
+ {
+ lock (ProgressLock)
+ {
+ UtcNowProvider = provider;
+ ResetThroughputStateUnlocked();
+ }
+ }
+
+ ///
+ /// Reports structured progress. Enriches download reports with measured throughput,
+ /// stores the snapshot as , and raises
+ /// . Never logs, never fails the operation.
+ /// Safe to call concurrently from output callbacks.
+ ///
+ protected void ReportProgress(OperationProgress progress)
+ {
+ OperationProgress enriched;
+ lock (ProgressLock)
+ {
+ enriched = EnrichWithThroughputUnlocked(progress);
+ CurrentProgress = enriched;
+ }
+ ProgressChanged?.Invoke(this, enriched);
+ }
+
+ ///
+ /// Resets structured progress to unknown (clears any speed). Propagates via
+ /// like any other report so retry/restart resets
+ /// reach the UI. Like , never touches the log.
+ ///
+ protected void ResetProgress() => ReportProgress(OperationProgress.Unknown);
+
+ private OperationProgress EnrichWithThroughputUnlocked(OperationProgress progress)
+ {
+ DateTime now = UtcNowProvider();
+
+ // Only the Downloading stage with full byte counters can carry a measured speed.
+ // Anything else (stage change away from Downloading, unknown progress, install
+ // percentages) clears the tracker and strips any attached speed.
+ if (
+ progress.Stage is not OperationProgressStage.Downloading
+ || progress.BytesDownloaded is null
+ || progress.BytesTotal is null
+ || progress.BytesTotal == 0
+ )
+ {
+ ResetThroughputStateUnlocked();
+ return progress.BytesPerSecond is null
+ ? progress
+ : progress with
+ {
+ BytesPerSecond = null,
+ };
+ }
+
+ ulong bytes = progress.BytesDownloaded.Value;
+
+ if (!HasThroughputBaseline)
+ {
+ // First sample establishes the baseline; there is no speed yet.
+ HasThroughputBaseline = true;
+ LastThroughputBytes = bytes;
+ LastThroughputSampleUtc = now;
+ SmoothedBytesPerSecond = null;
+ return progress with { BytesPerSecond = null };
+ }
+
+ if (bytes < LastThroughputBytes)
+ {
+ // Counter rewound (retry/restart): the old baseline is meaningless.
+ // The rewound sample becomes the new baseline; no stale speed survives.
+ LastThroughputBytes = bytes;
+ LastThroughputSampleUtc = now;
+ SmoothedBytesPerSecond = null;
+ return progress with { BytesPerSecond = null };
+ }
+
+ if (bytes == LastThroughputBytes)
+ {
+ // Stalled counter carries no new information: keep the previous speed instead
+ // of synthesizing one, but move the baseline clock forward so the stalled
+ // interval does not dilute the next real delta.
+ LastThroughputSampleUtc = now;
+ return progress with { BytesPerSecond = SmoothedBytesPerSecond };
+ }
+
+ TimeSpan elapsed = now - LastThroughputSampleUtc;
+ if (elapsed <= TimeSpan.Zero)
+ {
+ // No time elapsed: preserve the previous speed without dividing by zero.
+ return progress with { BytesPerSecond = SmoothedBytesPerSecond };
+ }
+
+ double instant = (bytes - LastThroughputBytes) / elapsed.TotalSeconds;
+ double smoothed =
+ SmoothedBytesPerSecond is { } previous
+ ? ThroughputSmoothingAlpha * instant + (1 - ThroughputSmoothingAlpha) * previous
+ : instant;
+
+ if (OperationProgress.NormalizeBytesPerSecond(smoothed) is null)
+ {
+ // Defensive: with a positive delta over positive time this cannot happen,
+ // but never let a non-usable speed leak downstream.
+ return progress with { BytesPerSecond = SmoothedBytesPerSecond };
+ }
+
+ SmoothedBytesPerSecond = smoothed;
+ LastThroughputBytes = bytes;
+ LastThroughputSampleUtc = now;
+ return progress with { BytesPerSecond = smoothed };
+ }
+
+ private void ResetThroughputStateUnlocked()
+ {
+ HasThroughputBaseline = false;
+ LastThroughputBytes = 0;
+ LastThroughputSampleUtc = default;
+ SmoothedBytesPerSecond = null;
+ }
+}
From 9486431d59764d86459dd5d5c3b9b7a722f3bd28 Mon Sep 17 00:00:00 2001
From: c <85012225+Cynrath@users.noreply.github.com>
Date: Fri, 18 Sep 2026 10:42:19 +0300
Subject: [PATCH 2/5] ui: show determinate operation progress and throughput
Surface real byte counters from the existing HTTP download path as
determinate card progress (percent, downloaded/total, measured MB/s) and
report role stage markers otherwise. WinGet CLI execution, retries,
elevation, proxy, return codes, and history behavior are unchanged;
piped winget.exe emits no progress frames, so those cards stay
indeterminate.
---
src/Languages/lang_en.json | 10 ++-
.../DialogPages/OperationViewModel.cs | 58 +++++++++++---
.../DownloadOperation.cs | 4 +
.../OperationCardProgressState.cs | 69 +++++++++++++++++
.../OperationProgressFormatter.cs | 77 +++++++++++++++++++
.../PackageOperations.cs | 20 +++++
6 files changed, 227 insertions(+), 11 deletions(-)
create mode 100644 src/UniGetUI.PackageEngine.Operations/OperationCardProgressState.cs
create mode 100644 src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs
diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json
index 5077c1ee21..37d94a1240 100644
--- a/src/Languages/lang_en.json
+++ b/src/Languages/lang_en.json
@@ -1088,5 +1088,13 @@
"Reset the default installer download location": "Reset the default installer download location",
"Open the default installer download location": "Open the default installer download location",
"{pm} could not be loaded": "{pm} could not be loaded",
- "{pm} was found on your system, but it could not be started. Check the UniGetUI log for more details.": "{pm} was found on your system, but it could not be started. Check the UniGetUI log for more details."
+ "{pm} was found on your system, but it could not be started. Check the UniGetUI log for more details.": "{pm} was found on your system, but it could not be started. Check the UniGetUI log for more details.",
+ "Downloading": "Downloading",
+ "Downloading...": "Downloading...",
+ "Installing": "Installing",
+ "Installing...": "Installing...",
+ "Updating": "Updating",
+ "Updating...": "Updating...",
+ "Uninstalling": "Uninstalling",
+ "Uninstalling...": "Uninstalling..."
}
diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
index d883a7f52d..a630d89744 100644
--- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
+++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
@@ -57,6 +57,19 @@ public sealed partial class OperationViewModel : ViewModelBase
private static readonly Uri _fallbackIconUri =
new("avares://UniGetUI/Assets/package_color.png");
+ // Pure mapping backing the progress visuals; the only UI-thread-owned copy.
+ // All event handlers below run on the UI thread via Dispatcher.UIThread.Post.
+ private OperationCardProgressState _card = new(
+ IsIndeterminate: false,
+ Value: 0,
+ LiveLine: ""
+ );
+
+ // Last log-driven status line. Structured determinate progress temporarily owns
+ // LiveLine; a plain Unknown reset (retry/restart) restores this so no stale
+ // formatted (speed-bearing) text survives the reset.
+ private string _lastLogLine = "";
+
public OperationViewModel(AbstractOperation operation)
{
Operation = operation;
@@ -75,7 +88,32 @@ public OperationViewModel(AbstractOperation operation)
// Route all background-thread events to the UI thread
operation.LogLineAdded += (_, ev) =>
- Dispatcher.UIThread.Post(() => LiveLine = ev.Item1);
+ Dispatcher.UIThread.Post(() =>
+ {
+ // Structured determinate progress owns the status line: raw per-frame
+ // progress text must not clobber it. (History already excludes
+ // ProgressIndicator lines, so this changes display only.)
+ if (
+ ev.Item2 is AbstractOperation.LineType.ProgressIndicator
+ && !_card.IsIndeterminate
+ )
+ return;
+ _card = _card with { LiveLine = ev.Item1 };
+ _lastLogLine = ev.Item1;
+ LiveLine = ev.Item1;
+ });
+
+ operation.ProgressChanged += (_, progress) =>
+ Dispatcher.UIThread.Post(() =>
+ {
+ _card = _card.WithProgress(Operation.Status, progress);
+ ProgressIndeterminate = _card.IsIndeterminate;
+ ProgressValue = _card.Value;
+ if (progress is null || progress.Stage is OperationProgressStage.Unknown)
+ LiveLine = _lastLogLine;
+ else
+ LiveLine = _card.LiveLine;
+ });
operation.StatusChanged += (_, status) =>
Dispatcher.UIThread.Post(() => ApplyStatus(status));
@@ -108,7 +146,13 @@ public OperationViewModel(AbstractOperation operation)
});
// Sync with current status in case the operation already started
+ _card = _card with { LiveLine = _liveLine };
+ _lastLogLine = _liveLine;
ApplyStatus(operation.Status);
+ _card = _card.WithProgress(operation.Status, operation.CurrentProgress);
+ ProgressIndeterminate = _card.IsIndeterminate;
+ ProgressValue = _card.Value;
+ LiveLine = _card.LiveLine;
}
// ── Icon loading ──────────────────────────────────────────────────────────
@@ -151,42 +195,36 @@ private async Task LoadIconAsync()
// ── Status → visual properties ────────────────────────────────────────────
private void ApplyStatus(OperationStatus status)
{
+ _card = _card.WithStatus(status);
+ ProgressIndeterminate = _card.IsIndeterminate;
+ ProgressValue = _card.Value;
switch (status)
{
case OperationStatus.InQueue:
- ProgressIndeterminate = false;
- ProgressValue = 0;
ProgressBrush = new SolidColorBrush(Color.Parse("#888888"));
BackgroundBrush = Brushes.Transparent;
ButtonText = CoreTools.Translate("Cancel");
break;
case OperationStatus.Running:
- ProgressIndeterminate = true;
ProgressBrush = new SolidColorBrush(Color.Parse("#F0A500"));
BackgroundBrush = new SolidColorBrush(Color.FromArgb(30, 240, 165, 0));
ButtonText = CoreTools.Translate("Cancel");
break;
case OperationStatus.Succeeded:
- ProgressIndeterminate = false;
- ProgressValue = 100;
ProgressBrush = new SolidColorBrush(Color.Parse("#0F7B0F"));
BackgroundBrush = new SolidColorBrush(Color.FromArgb(30, 15, 123, 15));
ButtonText = CoreTools.Translate("Close");
break;
case OperationStatus.Failed:
- ProgressIndeterminate = false;
- ProgressValue = 100;
ProgressBrush = new SolidColorBrush(Color.Parse("#BC0000"));
BackgroundBrush = new SolidColorBrush(Color.FromArgb(40, 188, 0, 0));
ButtonText = CoreTools.Translate("Close");
break;
case OperationStatus.Canceled:
- ProgressIndeterminate = false;
- ProgressValue = 100;
ProgressBrush = new SolidColorBrush(Color.Parse("#9D5D00"));
BackgroundBrush = Brushes.Transparent;
ButtonText = CoreTools.Translate("Close");
diff --git a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs
index 5d7734475b..7890f1bcd2 100644
--- a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs
+++ b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs
@@ -53,6 +53,7 @@ protected override async Task PerformOperation()
try
{
CancellationToken.ThrowIfCancellationRequested();
+ ReportProgress(OperationProgress.ForStage(OperationProgressStage.Downloading));
Line(
$"Fetching download url for package {_package.Name} from {_package.Manager.DisplayName}...",
LineType.Information
@@ -121,6 +122,9 @@ protected override async Task PerformOperation()
if (canReportProgress)
{
var progress = (int)((totalRead * 100L) / totalBytes);
+ ReportProgress(
+ OperationProgress.FromDownload((ulong)totalRead, (ulong)totalBytes)
+ );
if (progress != oldProgress)
{
oldProgress = progress;
diff --git a/src/UniGetUI.PackageEngine.Operations/OperationCardProgressState.cs b/src/UniGetUI.PackageEngine.Operations/OperationCardProgressState.cs
new file mode 100644
index 0000000000..e57da8b710
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Operations/OperationCardProgressState.cs
@@ -0,0 +1,69 @@
+using UniGetUI.PackageEngine.Enums;
+
+namespace UniGetUI.PackageOperations;
+
+///
+/// Pure, UI-framework-agnostic mapping from operation status plus generic
+/// to operation-card progress visuals.
+/// Extracted from OperationViewModel so the determinate/indeterminate contract is
+/// unit-testable without Avalonia. This type never touches the dispatcher or any UI
+/// control; the ViewModel remains the only UI-thread owner and copies
+/// , , and onto
+/// its bindable properties.
+///
+public sealed record OperationCardProgressState(
+ bool IsIndeterminate,
+ double Value,
+ string LiveLine
+)
+{
+ ///
+ /// Mirrors OperationViewModel.ApplyStatus for the progress visuals only
+ /// (brushes and button text stay in the ViewModel). Terminal statuses own the final
+ /// visuals with a full bar; queue resets to zero; running keeps the current value
+ /// and shows the indeterminate animation until real progress arrives.
+ ///
+ public OperationCardProgressState WithStatus(OperationStatus status) =>
+ status switch
+ {
+ OperationStatus.InQueue => this with { IsIndeterminate = false, Value = 0 },
+ OperationStatus.Running => this with { IsIndeterminate = true },
+ OperationStatus.Succeeded
+ or OperationStatus.Failed
+ or OperationStatus.Canceled => this with { IsIndeterminate = false, Value = 100 },
+ _ => this,
+ };
+
+ ///
+ /// Applies a structured progress report to a running card. Progress is only honored
+ /// while is ; reports
+ /// arriving after completion are ignored so terminal visuals win and stale reports
+ /// (including stale speeds) never leak back. Unknown progress keeps the
+ /// indeterminate animation; a known stage still updates the status text, while a
+ /// plain Unknown reset preserves the existing log-driven line.
+ ///
+ public OperationCardProgressState WithProgress(
+ OperationStatus status,
+ OperationProgress? progress
+ )
+ {
+ if (status is not OperationStatus.Running)
+ return this;
+
+ if (progress is null || !progress.IsDeterminate)
+ {
+ string liveLine =
+ progress is not null && progress.Stage is not OperationProgressStage.Unknown
+ ? OperationProgressFormatter.Format(progress)
+ : LiveLine;
+ return this with { IsIndeterminate = true, LiveLine = liveLine };
+ }
+
+ return this with
+ {
+ IsIndeterminate = false,
+ Value = Math.Clamp(progress.Percentage!.Value, 0, 100),
+ LiveLine = OperationProgressFormatter.Format(progress),
+ };
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs b/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs
new file mode 100644
index 0000000000..b664b67181
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs
@@ -0,0 +1,77 @@
+using UniGetUI.Core.Tools;
+using UniGetUI.PackageEngine.Enums;
+
+namespace UniGetUI.PackageOperations;
+
+///
+/// Formats a generic for operation cards, log lines,
+/// and screen-reader status. Unknown progress maps to a short stage label
+/// (indeterminate); determinate progress appends percent and, when available,
+/// human-readable byte counters plus the measured download throughput
+/// (e.g. "Downloading · 21% · 10.0 MB / 46.7 MB · 1.2 MB/s"). No ETA is shown.
+/// Size units reuse conventions; stage labels go
+/// through like every other user-facing string.
+///
+public static class OperationProgressFormatter
+{
+ public static string Format(OperationProgress progress)
+ {
+ ArgumentNullException.ThrowIfNull(progress);
+ if (!progress.IsDeterminate)
+ return IndeterminateLabel(progress.Stage);
+
+ int percent = (int)Math.Round(progress.Percentage!.Value);
+ string label = StageLabel(progress.Stage);
+ if (
+ progress.BytesDownloaded.HasValue
+ && progress.BytesTotal.HasValue
+ && progress.BytesTotal.Value > 0
+ )
+ {
+ string text =
+ $"{label} · {percent}% · {FormatBytes(progress.BytesDownloaded.Value)} / {FormatBytes(progress.BytesTotal.Value)}";
+ string? throughput = FormatThroughput(progress.BytesPerSecond);
+ return throughput is null ? text : $"{text} · {throughput}";
+ }
+
+ return $"{label} · {percent}%";
+ }
+
+ public static string StageLabel(OperationProgressStage stage) =>
+ stage switch
+ {
+ OperationProgressStage.Downloading => CoreTools.Translate("Downloading"),
+ OperationProgressStage.Installing => CoreTools.Translate("Installing"),
+ OperationProgressStage.Updating => CoreTools.Translate("Updating"),
+ OperationProgressStage.Uninstalling => CoreTools.Translate("Uninstalling"),
+ _ => CoreTools.Translate("Please wait..."),
+ };
+
+ private static string IndeterminateLabel(OperationProgressStage stage) =>
+ stage switch
+ {
+ OperationProgressStage.Downloading => CoreTools.Translate("Downloading..."),
+ OperationProgressStage.Installing => CoreTools.Translate("Installing..."),
+ OperationProgressStage.Updating => CoreTools.Translate("Updating..."),
+ OperationProgressStage.Uninstalling => CoreTools.Translate("Uninstalling..."),
+ _ => CoreTools.Translate("Please wait..."),
+ };
+
+ private static string FormatBytes(ulong value) =>
+ value > (ulong)long.MaxValue
+ ? $"{value / 1099511627776.0:F1} TB"
+ : CoreTools.FormatAsSize((long)value);
+
+ ///
+ /// Formats a measured throughput reusing units
+ /// with a "/s" suffix. Returns null when there is no usable speed, in which case the
+ /// caller keeps the established speed-less format.
+ ///
+ private static string? FormatThroughput(double? bytesPerSecond)
+ {
+ if (OperationProgress.NormalizeBytesPerSecond(bytesPerSecond) is not { } value)
+ return null;
+
+ return $"{CoreTools.FormatAsSize((long)value)}/s";
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
index d0b3f1d717..cd38734e4d 100644
--- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
+++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
@@ -296,6 +296,26 @@ .. ElevatorArgumentPrefix(),
///
protected override async Task PerformOperation()
{
+ // Observational stage marker only: structured progress starts as indeterminate
+ // with the role's stage label (e.g. "Installing..."). Re-reported on every
+ // attempt, so AutoRetry restarts also reset any previous speed. This changes
+ // nothing about execution, retries, elevation, proxy, or result handling.
+ ReportProgress(
+ Role switch
+ {
+ OperationType.Install => OperationProgress.ForStage(
+ OperationProgressStage.Installing
+ ),
+ OperationType.Update => OperationProgress.ForStage(
+ OperationProgressStage.Updating
+ ),
+ OperationType.Uninstall => OperationProgress.ForStage(
+ OperationProgressStage.Uninstalling
+ ),
+ _ => OperationProgress.Unknown,
+ }
+ );
+
if (!ShouldUseAgentBroker())
{
return await base.PerformOperation();
From a13fc357255b7db0cf851558236a3de64bb65f4c Mon Sep 17 00:00:00 2001
From: c <85012225+Cynrath@users.noreply.github.com>
Date: Fri, 18 Sep 2026 10:42:27 +0300
Subject: [PATCH 3/5] tests: cover progress model, throughput, mapping, and
winget-output regression
Model and single-clock throughput rules, card mapping, loopback-server
measured-speed test, and real captured winget download output proving
history preservation with no invented progress.
---
.../DownloadOperationThroughputTests.cs | 170 +++++
.../OperationCardProgressStateTests.cs | 242 +++++++
.../OperationProgressTests.cs | 652 ++++++++++++++++++
.../WingetCliOutputProgressRegressionTests.cs | 113 +++
4 files changed, 1177 insertions(+)
create mode 100644 src/UniGetUI.PackageEngine.Tests/DownloadOperationThroughputTests.cs
create mode 100644 src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs
create mode 100644 src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
create mode 100644 src/UniGetUI.PackageEngine.Tests/WingetCliOutputProgressRegressionTests.cs
diff --git a/src/UniGetUI.PackageEngine.Tests/DownloadOperationThroughputTests.cs b/src/UniGetUI.PackageEngine.Tests/DownloadOperationThroughputTests.cs
new file mode 100644
index 0000000000..a5538ff4e7
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/DownloadOperationThroughputTests.cs
@@ -0,0 +1,170 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageEngine.Interfaces;
+using UniGetUI.PackageEngine.Operations;
+using UniGetUI.PackageEngine.Tests.Infrastructure.Builders;
+using UniGetUI.PackageOperations;
+
+namespace UniGetUI.PackageEngine.Tests;
+
+///
+/// Proves the HTTP path surfaces measured download
+/// progress through the generic operation layer: a throttled loopback server streams a
+/// real payload with Content-Length, and at least one structured report must
+/// carry a finite positive BytesPerSecond derived from real bytes over real
+/// time. No synthetic speeds, no CLI parsing.
+///
+public sealed class DownloadOperationThroughputTests
+{
+ private sealed class ProbeDownloadOperation(IPackage package, string downloadPath)
+ : DownloadOperation(package, downloadPath)
+ {
+ public Task InvokePerformOperationForTests() =>
+ PerformOperation();
+ }
+
+ ///
+ /// Minimal throttled HTTP/1.1 server over loopback TCP: serves one fixed payload
+ /// with Content-Length, pacing chunks so the client's real clock observes
+ /// distinct progress samples with measurable throughput.
+ ///
+ private sealed class ThrottledLoopbackServer : IDisposable
+ {
+ private readonly TcpListener _listener;
+ private readonly byte[] _payload;
+ private readonly int _chunkSize;
+ private readonly TimeSpan _chunkDelay;
+ private readonly Task _serveTask;
+
+ public Uri Url { get; }
+
+ public ThrottledLoopbackServer(int totalBytes, int chunkSize, TimeSpan chunkDelay)
+ {
+ _payload = new byte[totalBytes];
+ new Random(42).NextBytes(_payload);
+ _chunkSize = chunkSize;
+ _chunkDelay = chunkDelay;
+ _listener = new TcpListener(IPAddress.Loopback, 0);
+ _listener.Start();
+ Url = new Uri(
+ $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/payload.bin"
+ );
+ _serveTask = Task.Run(ServeOnceAsync);
+ }
+
+ private async Task ServeOnceAsync()
+ {
+ using TcpClient client = await _listener.AcceptTcpClientAsync();
+ using NetworkStream stream = client.GetStream();
+
+ // Consume the request headers.
+ var request = new byte[4096];
+ int seen = 0;
+ while (seen < request.Length - 1)
+ {
+ int read = await stream.ReadAsync(request.AsMemory(seen));
+ if (read == 0)
+ break;
+ seen += read;
+ if (Encoding.ASCII.GetString(request, 0, seen).Contains("\r\n\r\n"))
+ break;
+ }
+
+ string header =
+ $"HTTP/1.1 200 OK\r\nContent-Length: {_payload.Length}\r\n"
+ + "Content-Type: application/octet-stream\r\nConnection: close\r\n\r\n";
+ byte[] headerBytes = Encoding.ASCII.GetBytes(header);
+ await stream.WriteAsync(headerBytes);
+ await stream.FlushAsync();
+
+ for (int offset = 0; offset < _payload.Length; offset += _chunkSize)
+ {
+ int count = Math.Min(_chunkSize, _payload.Length - offset);
+ await stream.WriteAsync(_payload.AsMemory(offset, count));
+ await stream.FlushAsync();
+ await Task.Delay(_chunkDelay);
+ }
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ _serveTask.Wait(TimeSpan.FromSeconds(30));
+ }
+ catch
+ {
+ // Best-effort: a failed transfer still ends the test via its verdict.
+ }
+ _listener.Stop();
+ }
+ }
+
+ [Fact]
+ public async Task HttpDownload_ReportsMeasuredThroughput()
+ {
+ const int TotalBytes = 3 * 1024 * 1024;
+ using var server = new ThrottledLoopbackServer(
+ TotalBytes,
+ chunkSize: 256 * 1024,
+ chunkDelay: TimeSpan.FromMilliseconds(100)
+ );
+
+ var manager = new PackageManagerBuilder()
+ .ConfigureDetails(helper =>
+ {
+ helper.PopulateDetails = details =>
+ {
+ details.InstallerUrl = server.Url;
+ details.InstallerType = "exe";
+ };
+ })
+ .Build();
+ IPackage package = new PackageBuilder().WithManager(manager).Build();
+
+ string downloadPath = Path.Join(
+ Path.GetTempPath(),
+ $"unigetui-throughput-{Guid.NewGuid():N}.bin"
+ );
+ try
+ {
+ using var operation = new ProbeDownloadOperation(package, downloadPath);
+ var seenSpeeds = new List();
+ operation.ProgressChanged += (_, progress) =>
+ {
+ lock (seenSpeeds)
+ seenSpeeds.Add(progress.BytesPerSecond);
+ };
+
+ OperationVeredict verdict = await operation.InvokePerformOperationForTests();
+
+ Assert.Equal(OperationVeredict.Success, verdict);
+ Assert.Equal(TotalBytes, new FileInfo(downloadPath).Length);
+
+ List speeds;
+ lock (seenSpeeds)
+ speeds = [.. seenSpeeds];
+ Assert.NotEmpty(speeds);
+ Assert.Contains(
+ speeds,
+ static speed =>
+ speed.HasValue
+ && !double.IsNaN(speed.Value)
+ && !double.IsInfinity(speed.Value)
+ && speed.Value > 0
+ );
+
+ // The enriched report formats with a live throughput suffix.
+ OperationProgress last = operation.CurrentProgress;
+ Assert.True(last.HasThroughput);
+ Assert.Contains("/s", OperationProgressFormatter.Format(last));
+ }
+ finally
+ {
+ if (File.Exists(downloadPath))
+ File.Delete(downloadPath);
+ }
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs b/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs
new file mode 100644
index 0000000000..dd1f71940c
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs
@@ -0,0 +1,242 @@
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageOperations;
+
+namespace UniGetUI.PackageEngine.Tests;
+
+///
+/// Direct coverage for the exact mapping uses for its
+/// operation card (indeterminate vs determinate, percent, byte/status text, retry/reset,
+/// terminal visuals). The mapping lives in so
+/// it runs without Avalonia; the ViewModel only copies the result onto bindable
+/// properties on the UI thread via Dispatcher.UIThread.Post.
+///
+public sealed class OperationCardProgressStateTests
+{
+ private const ulong OneMiB = 1024UL * 1024;
+
+ private static OperationCardProgressState FreshCard(string liveLine = "Please wait...") =>
+ new(IsIndeterminate: false, Value: 0, LiveLine: liveLine);
+
+ private static OperationCardProgressState RunningCard(string liveLine = "Please wait...") =>
+ FreshCard(liveLine).WithStatus(OperationStatus.Running);
+
+ [Fact]
+ public void Running_WithNoProgress_StaysIndeterminate()
+ {
+ var card = RunningCard().WithProgress(OperationStatus.Running, null);
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Equal("Please wait...", card.LiveLine);
+ }
+
+ [Fact]
+ public void Running_WithPlainUnknown_PreservesLogDrivenLine()
+ {
+ var card = RunningCard("Downloading installer...").WithProgress(
+ OperationStatus.Running,
+ OperationProgress.Unknown
+ );
+
+ Assert.True(card.IsIndeterminate);
+ // Plain Unknown resets must not overwrite the log-driven line.
+ Assert.Equal("Downloading installer...", card.LiveLine);
+ }
+
+ [Fact]
+ public void Running_WithKnownDownload_IsDeterminateWithPercentAndBytes()
+ {
+ var card = RunningCard().WithProgress(
+ OperationStatus.Running,
+ OperationProgress.FromDownload(50, 100)
+ );
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(50, card.Value);
+ Assert.Contains("50%", card.LiveLine);
+ // Byte counters are shown when both sides are known.
+ Assert.Contains("/", card.LiveLine);
+ }
+
+ [Fact]
+ public void Running_WithZeroPercent_IsDeterminate()
+ {
+ var card = RunningCard().WithProgress(
+ OperationStatus.Running,
+ OperationProgress.FromDownload(0, 100)
+ );
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(0, card.Value);
+ Assert.Contains("0%", card.LiveLine);
+ }
+
+ [Fact]
+ public void Running_DownloadWithSpeed_ShowsThroughputInLiveLine()
+ {
+ var progress = OperationProgress.FromDownload(21 * OneMiB, 100 * OneMiB)
+ with
+ {
+ BytesPerSecond = 1.2 * OneMiB,
+ };
+ var card = RunningCard().WithProgress(OperationStatus.Running, progress);
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Contains("21%", card.LiveLine);
+ Assert.Contains("/s", card.LiveLine);
+ }
+
+ [Fact]
+ public void Running_UnknownDownloadStage_ShowsDownloadingIndeterminate()
+ {
+ var card = RunningCard("Starting operation...").WithProgress(
+ OperationStatus.Running,
+ OperationProgress.ForStage(OperationProgressStage.Downloading)
+ );
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Contains("Downloading", card.LiveLine);
+ }
+
+ [Fact]
+ public void Running_UnknownInstallStage_ShowsInstallingIndeterminate()
+ {
+ var card = RunningCard().WithProgress(
+ OperationStatus.Running,
+ OperationProgress.ForStage(OperationProgressStage.Installing)
+ );
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Contains("Installing", card.LiveLine);
+ Assert.DoesNotContain("%", card.LiveLine);
+ }
+
+ [Fact]
+ public void Running_UnknownUpdateStage_ShowsUpdatingIndeterminate()
+ {
+ var card = RunningCard().WithProgress(
+ OperationStatus.Running,
+ OperationProgress.ForStage(OperationProgressStage.Updating)
+ );
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Contains("Updating", card.LiveLine);
+ }
+
+ [Fact]
+ public void Running_UnknownUninstallStage_ShowsUninstallingIndeterminate()
+ {
+ var card = RunningCard().WithProgress(
+ OperationStatus.Running,
+ OperationProgress.ForStage(OperationProgressStage.Uninstalling)
+ );
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Contains("Uninstalling", card.LiveLine);
+ }
+
+ [Fact]
+ public void DownloadingToInstalling_RemovesSpeed()
+ {
+ var progress = OperationProgress.FromDownload(21 * OneMiB, 100 * OneMiB)
+ with
+ {
+ BytesPerSecond = 1.2 * OneMiB,
+ };
+ var card = RunningCard().WithProgress(OperationStatus.Running, progress);
+
+ Assert.Contains("/s", card.LiveLine);
+
+ // Stage change away from Downloading: speed must not survive.
+ card = card.WithProgress(
+ OperationStatus.Running,
+ OperationProgress.ForStage(OperationProgressStage.Installing)
+ );
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Contains("Installing", card.LiveLine);
+ Assert.DoesNotContain("/s", card.LiveLine);
+ }
+
+ [Fact]
+ public void RetryReset_ReturnsToIndeterminate_KeepingLineForVmLogRestore()
+ {
+ var card = RunningCard("Starting operation...").WithProgress(
+ OperationStatus.Running,
+ OperationProgress.FromDownload(40, 100) with { BytesPerSecond = 1024 }
+ );
+ Assert.False(card.IsIndeterminate);
+ string determinateLine = card.LiveLine;
+
+ var reset = card.WithProgress(OperationStatus.Running, OperationProgress.Unknown);
+
+ Assert.True(reset.IsIndeterminate);
+ // The mapping never invents text: it keeps the line it holds. The ViewModel
+ // swaps this for its separately-tracked last log line, so the stale
+ // speed-bearing text never survives a retry reset on the real card.
+ Assert.Equal(determinateLine, reset.LiveLine);
+ }
+
+ [Theory]
+ [InlineData(OperationStatus.Succeeded)]
+ [InlineData(OperationStatus.Failed)]
+ [InlineData(OperationStatus.Canceled)]
+ public void Progress_AfterTerminal_IsIgnored_StaleSpeedCannotSurvive(OperationStatus status)
+ {
+ // Terminal visuals own the card: a stale speed-bearing report arriving after
+ // completion must not leak back into the visuals.
+ var card = RunningCard()
+ .WithProgress(
+ OperationStatus.Running,
+ OperationProgress.FromDownload(40, 100) with { BytesPerSecond = 1024 }
+ )
+ .WithStatus(status);
+ var before = card;
+
+ card = card.WithProgress(
+ status,
+ OperationProgress.FromDownload(90, 100) with { BytesPerSecond = 999_999 }
+ );
+
+ Assert.Equal(before, card);
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(100, card.Value);
+ }
+
+ [Theory]
+ [InlineData(OperationStatus.Succeeded)]
+ [InlineData(OperationStatus.Failed)]
+ [InlineData(OperationStatus.Canceled)]
+ public void TerminalStatus_OwnsFullBar(OperationStatus status)
+ {
+ var card = RunningCard().WithStatus(status);
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(100, card.Value);
+ }
+
+ [Fact]
+ public void InQueue_ResetsToZero()
+ {
+ var card = RunningCard()
+ .WithProgress(
+ OperationStatus.Running,
+ OperationProgress.FromDownload(40, 100)
+ )
+ .WithStatus(OperationStatus.InQueue);
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(0, card.Value);
+ }
+
+ [Fact]
+ public void OvershootPercentage_IsClampedOnCard()
+ {
+ var card = RunningCard().WithProgress(
+ OperationStatus.Running,
+ OperationProgress.FromDownload(150, 100)
+ );
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(100, card.Value);
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs b/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
new file mode 100644
index 0000000000..4a7e114d13
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
@@ -0,0 +1,652 @@
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageOperations;
+using LineType = UniGetUI.PackageOperations.AbstractOperation.LineType;
+
+namespace UniGetUI.PackageEngine.Tests;
+
+///
+/// Covers the manager-neutral model and the generic
+/// throughput tracker in : determinate/unknown rules,
+/// single-clock speed measurement, EMA smoothing, reset semantics, thread safety, and
+/// the guarantee that structured progress never touches the log/history path.
+///
+public sealed class OperationProgressTests
+{
+ private class ProgressProbeOperation : AbstractOperation
+ {
+ public ProgressProbeOperation()
+ : base(queue_enabled: false)
+ {
+ Metadata.Status = "probe status";
+ Metadata.Title = "probe title";
+ Metadata.OperationInformation = "probe info";
+ Metadata.SuccessTitle = "probe success";
+ Metadata.SuccessMessage = "probe success";
+ Metadata.FailureTitle = "probe failure";
+ Metadata.FailureMessage = "probe failure";
+ }
+
+ public void ReportForTests(OperationProgress progress) => ReportProgress(progress);
+
+ public void ResetForTests() => ResetProgress();
+
+ public void EmitForTests(string line, LineType type) => Line(line, type);
+
+ public void SetClockForTests(Func provider) =>
+ SetUtcNowProviderForTests(provider);
+
+ protected override void ApplyRetryAction(string retryMode) { }
+
+ protected override Task PerformOperation() =>
+ Task.FromResult(OperationVeredict.Success);
+
+ public override Task GetOperationIcon() =>
+ Task.FromResult(new Uri("avares://UniGetUI/Assets/package_color.png"));
+ }
+
+ ///
+ /// Deterministic manual clock. Production uses DateTime.UtcNow via the default
+ /// provider; tests advance time explicitly, which also proves the tracker honors
+ /// the injected clock (a DateTime.UtcNow leak would break the frozen-clock tests).
+ ///
+ private sealed class ManualClock
+ {
+ private DateTime _now = new(2026, 1, 12, 12, 0, 0, DateTimeKind.Utc);
+
+ public DateTime Now() => _now;
+
+ public void Advance(TimeSpan delta) => _now += delta;
+ }
+
+ private const ulong OneMiB = 1024UL * 1024;
+ private const ulong TenMiB = 10UL * 1024 * 1024;
+
+ private static (ProgressProbeOperation Op, ManualClock Clock) CreateClockedProbe()
+ {
+ var op = new ProgressProbeOperation();
+ var clock = new ManualClock();
+ op.SetClockForTests(clock.Now);
+ return (op, clock);
+ }
+
+ private static void ReportDownload(
+ ProgressProbeOperation op,
+ ulong downloaded,
+ ulong total = TenMiB
+ ) => op.ReportForTests(OperationProgress.FromDownload(downloaded, total));
+
+ // ── Model: determinate vs unknown ──────────────────────────────────────
+
+ [Fact]
+ public void Unknown_IsIndeterminate()
+ {
+ Assert.False(OperationProgress.Unknown.IsDeterminate);
+ Assert.Null(OperationProgress.Unknown.Percentage);
+ Assert.False(OperationProgress.Unknown.HasThroughput);
+ }
+
+ [Fact]
+ public void FromDownload_Mid_IsDeterminateWithDerivedPercentage()
+ {
+ var progress = OperationProgress.FromDownload(326, 624);
+
+ Assert.True(progress.IsDeterminate);
+ Assert.Equal(326UL, progress.BytesDownloaded);
+ Assert.Equal(624UL, progress.BytesTotal);
+ Assert.Equal(52, Math.Round(progress.Percentage!.Value));
+ }
+
+ [Fact]
+ public void FromDownload_ZeroBytes_IsDeterminateZero_NotUnknown()
+ {
+ var progress = OperationProgress.FromDownload(0, TenMiB);
+
+ Assert.True(progress.IsDeterminate);
+ Assert.Equal(0, progress.Percentage);
+ }
+
+ [Fact]
+ public void FromDownload_Full_IsDeterminateHundred()
+ {
+ var progress = OperationProgress.FromDownload(TenMiB, TenMiB);
+
+ Assert.True(progress.IsDeterminate);
+ Assert.Equal(100, progress.Percentage);
+ }
+
+ [Fact]
+ public void FromDownload_ZeroTotal_IsIndeterminate_NotFakeZero()
+ {
+ var progress = OperationProgress.FromDownload(1234, 0);
+
+ Assert.False(progress.IsDeterminate);
+ Assert.Null(progress.Percentage);
+ Assert.Equal(OperationProgressStage.Downloading, progress.Stage);
+ }
+
+ [Fact]
+ public void FromDownload_Overshoot_ClampsPercentageKeepsRealBytes()
+ {
+ var progress = OperationProgress.FromDownload(150, 100);
+
+ Assert.True(progress.IsDeterminate);
+ Assert.Equal(100, progress.Percentage);
+ Assert.Equal(150UL, progress.BytesDownloaded);
+ Assert.Equal(100UL, progress.BytesTotal);
+ }
+
+ [Theory]
+ [InlineData(34.0)]
+ [InlineData(0.0)]
+ [InlineData(100.0)]
+ public void FromInstall_Known_IsDeterminate(double percent)
+ {
+ var progress = OperationProgress.FromInstall(percent);
+
+ Assert.True(progress.IsDeterminate);
+ Assert.Equal(percent, progress.Percentage);
+ Assert.Equal(OperationProgressStage.Installing, progress.Stage);
+ }
+
+ [Theory]
+ [InlineData(150.0)]
+ public void FromInstall_AboveHundred_Clamps(double percent)
+ {
+ Assert.Equal(100, OperationProgress.FromInstall(percent).Percentage);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(-1.0)]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ public void FromInstall_Unknown_StaysIndeterminateWithoutFakePercent(double? percent)
+ {
+ var progress = OperationProgress.FromInstall(percent);
+
+ Assert.False(progress.IsDeterminate);
+ Assert.Null(progress.Percentage);
+ Assert.Equal(OperationProgressStage.Installing, progress.Stage);
+ }
+
+ [Fact]
+ public void FromUpdate_And_FromUninstall_CarryTheirStage()
+ {
+ Assert.Equal(
+ OperationProgressStage.Updating,
+ OperationProgress.FromUpdate(10).Stage
+ );
+ Assert.Equal(
+ OperationProgressStage.Uninstalling,
+ OperationProgress.FromUninstall(10).Stage
+ );
+ Assert.False(OperationProgress.FromUpdate(null).IsDeterminate);
+ Assert.False(OperationProgress.FromUninstall(null).IsDeterminate);
+ }
+
+ [Theory]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ [InlineData(0)]
+ [InlineData(-12.5)]
+ public void NormalizeBytesPerSecond_RejectsNonPositiveAndNonFinite(double value)
+ {
+ Assert.Null(OperationProgress.NormalizeBytesPerSecond(value));
+ Assert.False(
+ (OperationProgress.Unknown with { BytesPerSecond = value }).HasThroughput
+ );
+ }
+
+ [Fact]
+ public void NormalizeBytesPerSecond_KeepsPositiveFinite()
+ {
+ Assert.Equal(3.5, OperationProgress.NormalizeBytesPerSecond(3.5));
+ Assert.True(
+ (OperationProgress.Unknown with { BytesPerSecond = 3.5 }).HasThroughput
+ );
+ Assert.Null(OperationProgress.NormalizeBytesPerSecond(null));
+ }
+
+ // ── Throughput: one clock, real bytes over real time ───────────────────
+
+ [Fact]
+ public void FirstDownloadSample_HasNoSpeed()
+ {
+ var (op, _) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, OneMiB);
+
+ Assert.True(op.CurrentProgress.IsDeterminate);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ Assert.False(op.CurrentProgress.HasThroughput);
+ }
+ }
+
+ [Fact]
+ public void FrozenClock_SecondSample_HasNoSpeed_ProvesInjectedClockIsUsed()
+ {
+ // The clock never advances: elapsed time is exactly zero. A DateTime.UtcNow
+ // leak inside the tracker would observe real elapsed time and produce a
+ // (huge, fake) speed; the injected clock correctly yields no speed.
+ var (op, _) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ ReportDownload(op, OneMiB);
+
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ Assert.False(op.CurrentProgress.HasThroughput);
+ }
+ }
+
+ [Fact]
+ public void SecondValidSample_CalculatesDeltaBytesOverDeltaTime()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, OneMiB);
+
+ Assert.Equal((double)OneMiB, op.CurrentProgress.BytesPerSecond);
+ Assert.True(op.CurrentProgress.HasThroughput);
+ }
+ }
+
+ [Fact]
+ public void SpeedDelta_IsMeasuredFromPreviousSample()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, OneMiB);
+ clock.Advance(TimeSpan.FromSeconds(4));
+ ReportDownload(op, 3 * OneMiB);
+
+ // (3 MiB - 1 MiB) / 4 s = 0.5 MiB/s.
+ Assert.Equal((double)(OneMiB / 2), op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void Smoothing_IsDeterministicExponentialMovingAverage()
+ {
+ static double? RunSequence()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, OneMiB); // instant = 1 MiB/s
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 3 * OneMiB); // instant = 2 MiB/s
+ return op.CurrentProgress.BytesPerSecond;
+ }
+ }
+
+ double? first = RunSequence();
+ double? second = RunSequence();
+
+ Assert.NotNull(first);
+ Assert.Equal(first, second);
+ // EMA with alpha 0.3: 0.3 * 2 MiB/s + 0.7 * 1 MiB/s = 1.3 MiB/s.
+ Assert.InRange(first!.Value, 1.3 * OneMiB - 1, 1.3 * OneMiB + 1);
+ }
+
+ [Fact]
+ public void ZeroTimeDelta_PreservesPreviousSpeedWithoutNaN()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ // Second sample at the very same timestamp: no speed yet, no NaN.
+ ReportDownload(op, OneMiB);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 2 * OneMiB);
+ double? speed = op.CurrentProgress.BytesPerSecond;
+ Assert.NotNull(speed);
+
+ // More bytes but no time elapsed: previous speed preserved, finite.
+ ReportDownload(op, 3 * OneMiB);
+ Assert.Equal(speed, op.CurrentProgress.BytesPerSecond);
+ Assert.True(op.CurrentProgress.HasThroughput);
+ }
+ }
+
+ [Fact]
+ public void BackwardByteCounter_ResetsSpeedAndStartsNewBaseline()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 2 * OneMiB);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+
+ // Counter rewound (retry/restart): no stale speed survives.
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 512);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ // The rewound sample is the new baseline: next delta measures from it.
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 512 + OneMiB);
+ Assert.Equal((double)OneMiB, op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void RepeatedByteCount_PreservesPreviousSpeed()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, OneMiB);
+ double? speed = op.CurrentProgress.BytesPerSecond;
+ Assert.NotNull(speed);
+
+ // Stalled counter carries no new information: keep the previous speed
+ // instead of synthesizing a meaningless new one.
+ clock.Advance(TimeSpan.FromSeconds(5));
+ ReportDownload(op, OneMiB);
+ Assert.Equal(speed, op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void StageTransition_ResetsSpeed()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, OneMiB);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+
+ // Download -> install: speed is stripped, never carried over.
+ op.ReportForTests(OperationProgress.FromInstall(50));
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ Assert.False(op.CurrentProgress.HasThroughput);
+
+ // A fresh download starts without a stale speed.
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 2 * OneMiB);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void ResetProgress_ClearsSpeedAndReturnsToUnknown()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, OneMiB);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+
+ op.ResetForTests();
+ Assert.Equal(OperationProgress.Unknown, op.CurrentProgress);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ // Same counters after a reset behave like a first sample again.
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, OneMiB);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void UnknownProgress_ClearsSpeed()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, OneMiB);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+
+ op.ReportForTests(OperationProgress.Unknown);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 2 * OneMiB);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void NonDownloadingStages_NeverCarrySpeed()
+ {
+ var (op, _) = CreateClockedProbe();
+ using (op)
+ {
+ // Even a hand-built installing report with speed is sanitized.
+ op.ReportForTests(OperationProgress.FromInstall(50) with { BytesPerSecond = 999 });
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ op.ReportForTests(OperationProgress.Unknown with { BytesPerSecond = 999 });
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void EveryReport_PropagatesExactlyOneEvent()
+ {
+ using var op = new ProgressProbeOperation();
+ int events = 0;
+ op.ProgressChanged += (_, _) => events++;
+
+ // No throttling/coalescing: stage changes, determinate updates, and resets
+ // all propagate immediately on the reporting thread.
+ op.ReportForTests(OperationProgress.ForStage(OperationProgressStage.Downloading));
+ op.ReportForTests(OperationProgress.FromDownload(50, 100));
+ op.ResetForTests();
+
+ Assert.Equal(3, events);
+ }
+
+ [Fact]
+ public async Task RapidConcurrentReports_AreSafeAndFinite()
+ {
+ using var op = new ProgressProbeOperation();
+ var seenSpeeds = new System.Collections.Concurrent.ConcurrentBag();
+ op.ProgressChanged += (_, progress) => seenSpeeds.Add(progress.BytesPerSecond);
+
+ await Task.WhenAll(
+ Enumerable
+ .Range(0, 8)
+ .Select(worker =>
+ Task.Run(() =>
+ {
+ for (ulong step = 0; step < 50; step++)
+ op.ReportForTests(
+ OperationProgress.FromDownload(
+ (ulong)worker * 1000 + step,
+ 100_000
+ )
+ );
+ })
+ )
+ );
+
+ foreach (double? speed in seenSpeeds)
+ Assert.True(
+ speed is null
+ || (!double.IsNaN(speed.Value)
+ && !double.IsInfinity(speed.Value)
+ && speed.Value > 0),
+ $"Non-finite speed leaked: {speed}"
+ );
+
+ OperationProgress current = op.CurrentProgress;
+ Assert.True(current.IsDeterminate);
+ Assert.True(
+ current.BytesPerSecond is null
+ || (!double.IsNaN(current.BytesPerSecond.Value)
+ && !double.IsInfinity(current.BytesPerSecond.Value)
+ && current.BytesPerSecond.Value > 0)
+ );
+ }
+
+ // ── Separation: progress never touches log/history ─────────────────────
+
+ [Fact]
+ public void ReportProgress_DoesNotWriteToOperationOutput()
+ {
+ using var op = new ProgressProbeOperation();
+ var clock = new ManualClock();
+ op.SetClockForTests(clock.Now);
+
+ op.ReportForTests(OperationProgress.ForStage(OperationProgressStage.Downloading));
+ ReportDownload(op, OneMiB);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 2 * OneMiB);
+ op.ResetForTests();
+
+ // The constructor only emits a ProgressIndicator line, which is excluded
+ // from the output by design; structured reports add nothing at all.
+ Assert.Empty(op.GetOutput());
+ }
+
+ [Fact]
+ public void ProgressIndicatorLogLines_DoNotCreateStructuredProgress()
+ {
+ using var op = new ProgressProbeOperation();
+ int progressEvents = 0;
+ op.ProgressChanged += (_, _) => progressEvents++;
+
+ // Raw per-frame progress text flows through the normal log path only.
+ op.EmitForTests("[###.....] 30% (3.0 MB/10.0 MB)", LineType.ProgressIndicator);
+ op.EmitForTests("Fetching download url...", LineType.Information);
+
+ Assert.Equal(0, progressEvents);
+ Assert.Equal(OperationProgress.Unknown, op.CurrentProgress);
+ Assert.Single(op.GetOutput());
+ Assert.Equal("Fetching download url...", op.GetOutput()[0].Item1);
+ }
+
+ // ── Retry resets progress ──────────────────────────────────────────────
+
+ private sealed class AutoRetryProbeOperation : ProgressProbeOperation
+ {
+ private int _attempts;
+
+ protected override Task PerformOperation()
+ {
+ _attempts++;
+ if (_attempts == 1)
+ {
+ // First attempt reports real progress, then asks for a retry.
+ ReportProgress(OperationProgress.FromDownload(50, 100));
+ return Task.FromResult(OperationVeredict.AutoRetry);
+ }
+
+ // Retry restarts observationally (as PackageOperation does per attempt).
+ ReportProgress(OperationProgress.ForStage(OperationProgressStage.Downloading));
+ return Task.FromResult(OperationVeredict.Success);
+ }
+ }
+
+ [Fact]
+ public async Task AutoRetry_AttemptBoundary_ResetsToUnknown()
+ {
+ using var op = new AutoRetryProbeOperation();
+ var seen = new List();
+ op.ProgressChanged += (_, p) => seen.Add(p);
+
+ await op.MainThread();
+
+ Assert.Equal(OperationStatus.Succeeded, op.Status);
+ Assert.Contains(seen, static p => p is { IsDeterminate: true, Percentage: 50 });
+ // The retry attempt restarts observationally with indeterminate progress and
+ // no speed, after the determinate report of the first attempt.
+ int determinateIndex = seen.FindIndex(
+ static p => p is { IsDeterminate: true, Percentage: 50 }
+ );
+ Assert.True(determinateIndex >= 0);
+ Assert.Contains(
+ seen.Skip(determinateIndex + 1),
+ static p => !p.IsDeterminate
+ && p.Stage == OperationProgressStage.Downloading
+ && p.BytesPerSecond is null
+ );
+ }
+
+ // ── Formatter ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Formatter_DeterminateDownload_IncludesPercentAndByteCounters()
+ {
+ string text = OperationProgressFormatter.Format(
+ OperationProgress.FromDownload(21 * OneMiB, 100 * OneMiB)
+ );
+
+ Assert.Contains("21%", text);
+ Assert.Contains("/", text);
+ Assert.DoesNotContain("/s", text);
+ }
+
+ [Fact]
+ public void Formatter_DownloadWithSpeed_AppendsThroughput()
+ {
+ var progress =
+ OperationProgress.FromDownload(21 * OneMiB, 100 * OneMiB)
+ with
+ {
+ BytesPerSecond = 1.2 * OneMiB,
+ };
+
+ string text = OperationProgressFormatter.Format(progress);
+
+ Assert.Contains("21%", text);
+ Assert.Contains("/", text);
+ Assert.Contains("/s", text);
+ Assert.Contains("MB", text);
+ }
+
+ [Fact]
+ public void Formatter_IndeterminateInstall_ShowsStageWithoutPercent()
+ {
+ string text = OperationProgressFormatter.Format(
+ OperationProgress.FromInstall(null)
+ );
+
+ Assert.Contains("Installing", text);
+ Assert.DoesNotContain("%", text);
+ }
+
+ [Fact]
+ public void Formatter_Unknown_DoesNotThrow()
+ {
+ Assert.False(string.IsNullOrWhiteSpace(OperationProgressFormatter.Format(OperationProgress.Unknown)));
+ }
+
+ [Theory]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ [InlineData(0)]
+ [InlineData(-5)]
+ public void Formatter_UnusableSpeed_IsOmitted(double bytesPerSecond)
+ {
+ var progress =
+ OperationProgress.FromDownload(50, 100) with { BytesPerSecond = bytesPerSecond };
+
+ Assert.DoesNotContain("/s", OperationProgressFormatter.Format(progress));
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Tests/WingetCliOutputProgressRegressionTests.cs b/src/UniGetUI.PackageEngine.Tests/WingetCliOutputProgressRegressionTests.cs
new file mode 100644
index 0000000000..ee5e655e87
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/WingetCliOutputProgressRegressionTests.cs
@@ -0,0 +1,113 @@
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageOperations;
+using LineType = UniGetUI.PackageOperations.AbstractOperation.LineType;
+
+namespace UniGetUI.PackageEngine.Tests;
+
+///
+/// Regression evidence for the §5 determination: piped WinGet CLI output carries no
+/// reliable progress frames, so it must flow through the normal log/history path only
+/// and must never produce structured (determinate) progress.
+///
+/// Fixtures are the sanitized lines actually captured from
+/// winget download --id 7zip.7zip --exact --accept-source-agreements
+/// --disable-interactivity --accept-package-agreements (winget v1.29.290) with
+/// stdout redirected: six plain CR LF lines over an ~8.5 s download, empty stderr,
+/// no ANSI escapes, no byte counters, no percentages. The test replays them exactly as
+/// splits them (CR-terminated text becomes a
+/// line, promoted to
+/// by the bare LF that follows) and asserts history
+/// preservation plus the absence of invented progress.
+///
+public sealed class WingetCliOutputProgressRegressionTests
+{
+ private sealed class ProgressProbeOperation : AbstractOperation
+ {
+ public ProgressProbeOperation()
+ : base(queue_enabled: false)
+ {
+ Metadata.Status = "probe status";
+ Metadata.Title = "probe title";
+ Metadata.OperationInformation = "probe info";
+ Metadata.SuccessTitle = "probe success";
+ Metadata.SuccessMessage = "probe success";
+ Metadata.FailureTitle = "probe failure";
+ Metadata.FailureMessage = "probe failure";
+ }
+
+ public void EmitForTests(string line, LineType type) => Line(line, type);
+
+ protected override void ApplyRetryAction(string retryMode) { }
+
+ protected override Task PerformOperation() =>
+ Task.FromResult(OperationVeredict.Success);
+
+ public override Task GetOperationIcon() =>
+ Task.FromResult(new Uri("avares://UniGetUI/Assets/package_color.png"));
+ }
+
+ ///
+ /// Sanitized raw capture: only the user-specific download target path was replaced.
+ ///
+ private static readonly string[] RealCapturedWingetDownloadLines =
+ [
+ "Found 7-Zip [7zip.7zip] Version 26.03",
+ "This application is licensed to you by its owner.",
+ "Microsoft is not responsible for, nor does it grant any licenses to, third-party packages.",
+ "Downloading https://www.7-zip.org/a/7z2603-x64.msi",
+ "Successfully verified installer hash",
+ "Installer downloaded: \\7-Zip_26.03_Machine_X64_wix_en-US.msi",
+ ];
+
+ private static void ReplayAsProcessReaderWouldSplit(
+ ProgressProbeOperation op,
+ string rawLine
+ )
+ {
+ // AbstractProcessOperation: text terminated by CR is emitted as a progress
+ // indicator; the bare LF that follows promotes it to a regular line.
+ op.EmitForTests(rawLine, LineType.ProgressIndicator);
+ op.EmitForTests(rawLine, LineType.Information);
+ }
+
+ [Fact]
+ public void RealWingetDownloadOutput_PreservedInHistory_CreatesNoStructuredProgress()
+ {
+ using var op = new ProgressProbeOperation();
+ int progressEvents = 0;
+ op.ProgressChanged += (_, _) => progressEvents++;
+
+ foreach (string line in RealCapturedWingetDownloadLines)
+ ReplayAsProcessReaderWouldSplit(op, line);
+
+ // Detailed CLI output is preserved for troubleshooting/history: progress
+ // indicator frames stay out of the stored output, regular lines stay in.
+ var stored = op.GetOutput();
+ Assert.Equal(RealCapturedWingetDownloadLines.Length, stored.Count);
+ Assert.Equal(
+ RealCapturedWingetDownloadLines,
+ stored.Select(entry => entry.Item1).ToArray()
+ );
+ Assert.All(stored, static entry => Assert.Equal(LineType.Information, entry.Item2));
+
+ // And no determinate progress is invented from lines that carry no numbers.
+ Assert.Equal(0, progressEvents);
+ Assert.Equal(OperationProgress.Unknown, op.CurrentProgress);
+ }
+
+ [Fact]
+ public void RealWingetDownloadOutput_ContainsNoParsableProgressSignals()
+ {
+ // Pins the §5 evidence: if a future winget version adds byte counters or
+ // percentages to piped output, this documents the exact previously-observed
+ // shape that justified staying indeterminate.
+ foreach (string line in RealCapturedWingetDownloadLines)
+ {
+ Assert.DoesNotContain("%", line, StringComparison.Ordinal);
+ Assert.DoesNotContain("MB", line, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("KB", line, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("GB", line, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("B/s", line, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+}
From ec4b865dc0c994cea9f67b9586d2fbf146bf679d Mon Sep 17 00:00:00 2001
From: c <85012225+Cynrath@users.noreply.github.com>
Date: Fri, 18 Sep 2026 11:04:11 +0300
Subject: [PATCH 4/5] fix: gate raw progress lines only while determinate
progress owns the card
The ProgressIndicator gate used card indeterminacy, which is also false
while queued or before any report arrives. That hid status/queue lines
and left verbose OperationInformation text stuck on the card. Gate only
while a determinate report is active; clear ownership when leaving
Running.
---
.../ViewModels/DialogPages/OperationViewModel.cs | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
index a630d89744..57d52866bd 100644
--- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
+++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
@@ -70,6 +70,12 @@ public sealed partial class OperationViewModel : ViewModelBase
// formatted (speed-bearing) text survives the reset.
private string _lastLogLine = "";
+ // True only while a determinate structured report owns the status line. Raw
+ // per-frame progress text is gated out solely in that case; in every other
+ // state (queue, indeterminate, terminal, or no report ever received) log lines
+ // flow to the card exactly as before, so status/queue lines are never hidden.
+ private bool _determinateProgressActive;
+
public OperationViewModel(AbstractOperation operation)
{
Operation = operation;
@@ -95,7 +101,7 @@ public OperationViewModel(AbstractOperation operation)
// ProgressIndicator lines, so this changes display only.)
if (
ev.Item2 is AbstractOperation.LineType.ProgressIndicator
- && !_card.IsIndeterminate
+ && _determinateProgressActive
)
return;
_card = _card with { LiveLine = ev.Item1 };
@@ -107,6 +113,9 @@ ev.Item2 is AbstractOperation.LineType.ProgressIndicator
Dispatcher.UIThread.Post(() =>
{
_card = _card.WithProgress(Operation.Status, progress);
+ _determinateProgressActive =
+ Operation.Status is OperationStatus.Running
+ && progress?.IsDeterminate is true;
ProgressIndeterminate = _card.IsIndeterminate;
ProgressValue = _card.Value;
if (progress is null || progress.Stage is OperationProgressStage.Unknown)
@@ -196,6 +205,10 @@ private async Task LoadIconAsync()
private void ApplyStatus(OperationStatus status)
{
_card = _card.WithStatus(status);
+ // Determinate ownership ends with the running phase; afterwards log lines
+ // (e.g. the success/failure message) own the status line again.
+ _determinateProgressActive =
+ status is OperationStatus.Running && _determinateProgressActive;
ProgressIndeterminate = _card.IsIndeterminate;
ProgressValue = _card.Value;
switch (status)
From ac9284f3dce04947d47877e6f9099451af1288f0 Mon Sep 17 00:00:00 2001
From: c <85012225+Cynrath@users.noreply.github.com>
Date: Fri, 18 Sep 2026 23:26:30 +0300
Subject: [PATCH 5/5] fix: address operation progress review feedback
- Throttle DownloadOperation progress to integer-percent gate (bounded UI events, 0/100 preserved)
- Remove dead FromInstall/FromUpdate/FromUninstall/FromStagePercent and ResetProgress
- Delete WingetCliOutputProgressRegressionTests (fixture-only)
- Fix OperationViewModel constructor determinate init via OperationCardController
- Add direct controller state-machine tests (construct, failure order, retry, stage)
- Switch throughput to monotonic Stopwatch timestamps (injectable)
- Add stale-speed expiry (2s, timer only while fresh, disposed cleanly)
- Isolate ProgressChanged subscribers (observational, per-subscriber try/catch)
- Localize full progress templates, drop duplicate ellipsis keys and TB branch
- Shrink suite to focused coverage (37 tests, both TFMs)
---
src/Languages/lang_en.json | 8 +-
.../DialogPages/OperationViewModel.cs | 85 ++--
.../OperationProgress.cs | 33 --
.../AbstractOperation.cs | 1 +
.../AbstractOperation_Progress.cs | 309 +++++++++++--
.../DownloadOperation.cs | 18 +-
.../OperationCardController.cs | 116 +++++
.../OperationCardProgressState.cs | 9 +-
.../OperationProgressFormatter.cs | 52 ++-
.../DownloadOperationProgressTests.cs | 226 +++++++++
.../DownloadOperationThroughputTests.cs | 170 -------
.../OperationCardControllerTests.cs | 167 +++++++
.../OperationCardProgressStateTests.cs | 191 +-------
.../OperationProgressTests.cs | 432 +++++-------------
.../WingetCliOutputProgressRegressionTests.cs | 113 -----
15 files changed, 994 insertions(+), 936 deletions(-)
create mode 100644 src/UniGetUI.PackageEngine.Operations/OperationCardController.cs
create mode 100644 src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs
delete mode 100644 src/UniGetUI.PackageEngine.Tests/DownloadOperationThroughputTests.cs
create mode 100644 src/UniGetUI.PackageEngine.Tests/OperationCardControllerTests.cs
delete mode 100644 src/UniGetUI.PackageEngine.Tests/WingetCliOutputProgressRegressionTests.cs
diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json
index 37d94a1240..6be8a50392 100644
--- a/src/Languages/lang_en.json
+++ b/src/Languages/lang_en.json
@@ -1090,11 +1090,11 @@
"{pm} could not be loaded": "{pm} could not be loaded",
"{pm} was found on your system, but it could not be started. Check the UniGetUI log for more details.": "{pm} was found on your system, but it could not be started. Check the UniGetUI log for more details.",
"Downloading": "Downloading",
- "Downloading...": "Downloading...",
"Installing": "Installing",
- "Installing...": "Installing...",
"Updating": "Updating",
- "Updating...": "Updating...",
"Uninstalling": "Uninstalling",
- "Uninstalling...": "Uninstalling..."
+ "{0}...": "{0}...",
+ "{0} · {1}%": "{0} · {1}%",
+ "{0} · {1}% · {2} / {3}": "{0} · {1}% · {2} / {3}",
+ "{0} · {1}% · {2} / {3} · {4}": "{0} · {1}% · {2} / {3} · {4}"
}
diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
index 57d52866bd..5b5ff7f5a7 100644
--- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
+++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
@@ -57,24 +57,12 @@ public sealed partial class OperationViewModel : ViewModelBase
private static readonly Uri _fallbackIconUri =
new("avares://UniGetUI/Assets/package_color.png");
- // Pure mapping backing the progress visuals; the only UI-thread-owned copy.
- // All event handlers below run on the UI thread via Dispatcher.UIThread.Post.
- private OperationCardProgressState _card = new(
- IsIndeterminate: false,
- Value: 0,
- LiveLine: ""
- );
-
- // Last log-driven status line. Structured determinate progress temporarily owns
- // LiveLine; a plain Unknown reset (retry/restart) restores this so no stale
- // formatted (speed-bearing) text survives the reset.
- private string _lastLogLine = "";
-
- // True only while a determinate structured report owns the status line. Raw
- // per-frame progress text is gated out solely in that case; in every other
- // state (queue, indeterminate, terminal, or no report ever received) log lines
- // flow to the card exactly as before, so status/queue lines are never hidden.
- private bool _determinateProgressActive;
+ // Progress display state machine; the only UI-thread-owned copy.
+ // All event handlers below run on the UI thread via Dispatcher.UIThread.Post,
+ // which is FIFO at the same priority. That ordering is load-bearing on the
+ // failure path: Status=Failed clears determinate ownership before the failure
+ // line arrives, so the failure message is never swallowed.
+ private readonly OperationCardController _controller = new();
public OperationViewModel(AbstractOperation operation)
{
@@ -99,29 +87,22 @@ public OperationViewModel(AbstractOperation operation)
// Structured determinate progress owns the status line: raw per-frame
// progress text must not clobber it. (History already excludes
// ProgressIndicator lines, so this changes display only.)
- if (
- ev.Item2 is AbstractOperation.LineType.ProgressIndicator
- && _determinateProgressActive
- )
- return;
- _card = _card with { LiveLine = ev.Item1 };
- _lastLogLine = ev.Item1;
- LiveLine = ev.Item1;
+ if (_controller.TryApplyLogLine(ev.Item1, ev.Item2, out string liveLine))
+ {
+ LiveLine = liveLine;
+ }
});
operation.ProgressChanged += (_, progress) =>
Dispatcher.UIThread.Post(() =>
{
- _card = _card.WithProgress(Operation.Status, progress);
- _determinateProgressActive =
- Operation.Status is OperationStatus.Running
- && progress?.IsDeterminate is true;
- ProgressIndeterminate = _card.IsIndeterminate;
- ProgressValue = _card.Value;
- if (progress is null || progress.Stage is OperationProgressStage.Unknown)
- LiveLine = _lastLogLine;
- else
- LiveLine = _card.LiveLine;
+ var (isIndeterminate, value, liveLine) = _controller.ApplyProgress(
+ Operation.Status,
+ progress
+ );
+ ProgressIndeterminate = isIndeterminate;
+ ProgressValue = value;
+ LiveLine = liveLine;
});
operation.StatusChanged += (_, status) =>
@@ -154,14 +135,17 @@ Operation.Status is OperationStatus.Running
));
});
- // Sync with current status in case the operation already started
- _card = _card with { LiveLine = _liveLine };
- _lastLogLine = _liveLine;
- ApplyStatus(operation.Status);
- _card = _card.WithProgress(operation.Status, operation.CurrentProgress);
- ProgressIndeterminate = _card.IsIndeterminate;
- ProgressValue = _card.Value;
- LiveLine = _card.LiveLine;
+ // Sync with current status in case the operation already started. The
+ // controller derives card, last log line, and determinate ownership from the
+ // same snapshot so a mid-download card starts in the formatted state.
+ // Note: SyncInitial already applies the status to the controller, so only
+ // the brush/menu visuals still need syncing here (a second ApplyStatus would
+ // flip a determinate Running card back to indeterminate).
+ _controller.SyncInitial(_liveLine, operation.Status, operation.CurrentProgress);
+ ProgressIndeterminate = _controller.Card.IsIndeterminate;
+ ProgressValue = _controller.Card.Value;
+ LiveLine = _controller.Card.LiveLine;
+ ApplyStatusVisuals(operation.Status);
}
// ── Icon loading ──────────────────────────────────────────────────────────
@@ -204,13 +188,16 @@ private async Task LoadIconAsync()
// ── Status → visual properties ────────────────────────────────────────────
private void ApplyStatus(OperationStatus status)
{
- _card = _card.WithStatus(status);
// Determinate ownership ends with the running phase; afterwards log lines
// (e.g. the success/failure message) own the status line again.
- _determinateProgressActive =
- status is OperationStatus.Running && _determinateProgressActive;
- ProgressIndeterminate = _card.IsIndeterminate;
- ProgressValue = _card.Value;
+ _controller.ApplyStatus(status);
+ ProgressIndeterminate = _controller.Card.IsIndeterminate;
+ ProgressValue = _controller.Card.Value;
+ ApplyStatusVisuals(status);
+ }
+
+ private void ApplyStatusVisuals(OperationStatus status)
+ {
switch (status)
{
case OperationStatus.InQueue:
diff --git a/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs b/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
index 3c9980cc39..b462be21dc 100644
--- a/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
+++ b/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
@@ -59,39 +59,6 @@ public static OperationProgress FromDownload(ulong downloaded, ulong total) =>
null
);
- ///
- /// Install progress from an explicitly reported percentage. Null, non-finite, or
- /// negative values mean the installer supplied no usable progress and map to
- /// indeterminate rather than a fake number.
- ///
- public static OperationProgress FromInstall(double? percent) =>
- FromStagePercent(OperationProgressStage.Installing, percent);
-
- ///
- /// Update progress from an explicitly reported percentage. Same unknown semantics
- /// as .
- ///
- public static OperationProgress FromUpdate(double? percent) =>
- FromStagePercent(OperationProgressStage.Updating, percent);
-
- ///
- /// Uninstall progress from an explicitly reported percentage. Same unknown semantics
- /// as .
- ///
- public static OperationProgress FromUninstall(double? percent) =>
- FromStagePercent(OperationProgressStage.Uninstalling, percent);
-
- private static OperationProgress FromStagePercent(
- OperationProgressStage stage,
- double? percent
- ) =>
- percent is { } value
- && !double.IsNaN(value)
- && !double.IsInfinity(value)
- && value >= 0
- ? new(stage, Math.Min(value, 100.0), null, null, null)
- : new(stage, null, null, null, null);
-
///
/// True only for a real, finite percentage. Unknown progress is never zero.
///
diff --git a/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
index dacb78df0c..f0d50dd305 100644
--- a/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
+++ b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
@@ -760,6 +760,7 @@ protected virtual void Dispose(bool disposing)
scheduledRetry?.TrySetCanceled();
Cancel();
+ DisposeStaleSpeedTimer();
if (!IsExecutingOperation)
{
while (OperationQueue.Remove(this))
diff --git a/src/UniGetUI.PackageEngine.Operations/AbstractOperation_Progress.cs b/src/UniGetUI.PackageEngine.Operations/AbstractOperation_Progress.cs
index aa52d0c3d7..bf2680a306 100644
--- a/src/UniGetUI.PackageEngine.Operations/AbstractOperation_Progress.cs
+++ b/src/UniGetUI.PackageEngine.Operations/AbstractOperation_Progress.cs
@@ -1,3 +1,5 @@
+using System.Diagnostics;
+using UniGetUI.Core.Logging;
using UniGetUI.PackageEngine.Enums;
namespace UniGetUI.PackageOperations;
@@ -9,18 +11,23 @@ namespace UniGetUI.PackageOperations;
// - Structured progress callbacks and Line() logging are fully separated. ReportProgress
// never logs; existing CLI output, return codes, retry decisions, and history entries
// are unaffected by progress reporting.
-// - ONE clock source: _utcNowProvider (default DateTime.UtcNow, injectable for tests) is
-// the only time source used by progress logic. There is no time-based UI throttling,
-// so there is no second clock to drift.
+// - ONE monotonic clock source: _timestampProvider (default Stopwatch.GetTimestamp,
+// injectable for tests) is the only time source used by progress logic. Elapsed
+// intervals come from Stopwatch.GetElapsedTime so wall-clock adjustments never skew
+// throughput. There is no time-based UI throttling, so there is no second clock.
// - Speed is measured generically as deltaBytes / deltaTime from real cumulative byte
-// samples. First sample, rewound counters, stalled counters, zero elapsed time, stage
-// changes, and unknown progress never synthesize a speed. Light deterministic EMA
-// smoothing (alpha 0.3) damps per-chunk network jitter.
+// samples. First sample, rewound counters, zero elapsed time, stage changes, and
+// unknown progress never synthesize a speed. Light deterministic EMA smoothing
+// (alpha 0.3) damps per-chunk network jitter.
+// - Stale speed expires: when no fresh byte movement is observed for StaleSpeedTimeout
+// (default 2 s), the speed suffix is dropped rather than displayed indefinitely.
+// A single one-shot timer exists only while a fresh speed is active; it is stopped
+// on stage change/reset/completion/disposal and never affects execution.
// - No ETA is computed.
public abstract partial class AbstractOperation
{
///
- /// Raised on the reporting thread whenever structured progress is reported or reset.
+ /// Raised on the reporting thread whenever structured progress is reported or expired.
/// UI subscribers must marshal to the UI thread (as with LogLineAdded/StatusChanged).
/// Never raised from the log path; progress lines in the log do not raise this.
///
@@ -30,34 +37,50 @@ public abstract partial class AbstractOperation
public OperationProgress CurrentProgress { get; private set; } = OperationProgress.Unknown;
private readonly object ProgressLock = new();
- private Func UtcNowProvider = static () => DateTime.UtcNow;
+ private Func TimestampProvider = static () => Stopwatch.GetTimestamp();
// Throughput tracker state. All fields are guarded by ProgressLock.
private bool HasThroughputBaseline;
private ulong LastThroughputBytes;
- private DateTime LastThroughputSampleUtc;
+ private long LastThroughputTimestamp;
+ private long LastFreshTimestamp;
private double? SmoothedBytesPerSecond;
private const double ThroughputSmoothingAlpha = 0.3;
///
- /// Test hook: replaces the single clock used by progress logic. Resets tracker state
- /// so samples from different clocks are never mixed.
+ /// Age after which a previously measured speed is considered stale and omitted
+ /// from display. Fresh byte movement restores it.
///
- internal void SetUtcNowProviderForTests(Func provider)
+ internal TimeSpan StaleSpeedTimeout { get; set; } = TimeSpan.FromSeconds(2);
+
+ private Timer? StaleSpeedTimer;
+ private bool StaleTimerArmed;
+
+ ///
+ /// Test hook: replaces the single monotonic clock used by progress logic. Resets
+ /// tracker state so samples from different clocks are never mixed.
+ ///
+ internal void SetTimestampProviderForTests(Func provider)
{
lock (ProgressLock)
{
- UtcNowProvider = provider;
+ TimestampProvider = provider;
ResetThroughputStateUnlocked();
+ DisarmStaleTimerUnlocked();
}
}
///
/// Reports structured progress. Enriches download reports with measured throughput,
/// stores the snapshot as , and raises
- /// . Never logs, never fails the operation.
+ /// . Never logs and never fails the operation:
+ /// each subscriber is isolated so a display-layer exception cannot fail package
+ /// execution nor block later subscribers.
/// Safe to call concurrently from output callbacks.
+ /// Callbacks run outside ProgressLock (holding a lock across subscriber
+ /// code invites deadlock), so callback ordering is only guaranteed for a single
+ /// reporting thread.
///
protected void ReportProgress(OperationProgress progress)
{
@@ -67,23 +90,39 @@ protected void ReportProgress(OperationProgress progress)
enriched = EnrichWithThroughputUnlocked(progress);
CurrentProgress = enriched;
}
- ProgressChanged?.Invoke(this, enriched);
+ NotifyProgressSubscribers(enriched);
}
- ///
- /// Resets structured progress to unknown (clears any speed). Propagates via
- /// like any other report so retry/restart resets
- /// reach the UI. Like , never touches the log.
- ///
- protected void ResetProgress() => ReportProgress(OperationProgress.Unknown);
+ private void NotifyProgressSubscribers(OperationProgress enriched)
+ {
+ var handlers = ProgressChanged?.GetInvocationList();
+ if (handlers is null)
+ {
+ return;
+ }
+
+ foreach (EventHandler handler in handlers)
+ {
+ try
+ {
+ handler(this, enriched);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn(
+ $"A progress subscriber threw; progress reporting is observational and will not fail the operation: {ex}"
+ );
+ }
+ }
+ }
private OperationProgress EnrichWithThroughputUnlocked(OperationProgress progress)
{
- DateTime now = UtcNowProvider();
+ long now = TimestampProvider();
// Only the Downloading stage with full byte counters can carry a measured speed.
- // Anything else (stage change away from Downloading, unknown progress, install
- // percentages) clears the tracker and strips any attached speed.
+ // Anything else (stage change away from Downloading, unknown progress) clears
+ // the tracker and strips any attached speed.
if (
progress.Stage is not OperationProgressStage.Downloading
|| progress.BytesDownloaded is null
@@ -92,6 +131,7 @@ progress.Stage is not OperationProgressStage.Downloading
)
{
ResetThroughputStateUnlocked();
+ DisarmStaleTimerUnlocked();
return progress.BytesPerSecond is null
? progress
: progress with
@@ -107,8 +147,10 @@ progress.Stage is not OperationProgressStage.Downloading
// First sample establishes the baseline; there is no speed yet.
HasThroughputBaseline = true;
LastThroughputBytes = bytes;
- LastThroughputSampleUtc = now;
+ LastThroughputTimestamp = now;
+ LastFreshTimestamp = now;
SmoothedBytesPerSecond = null;
+ DisarmStaleTimerUnlocked();
return progress with { BytesPerSecond = null };
}
@@ -117,21 +159,35 @@ progress.Stage is not OperationProgressStage.Downloading
// Counter rewound (retry/restart): the old baseline is meaningless.
// The rewound sample becomes the new baseline; no stale speed survives.
LastThroughputBytes = bytes;
- LastThroughputSampleUtc = now;
+ LastThroughputTimestamp = now;
+ LastFreshTimestamp = now;
SmoothedBytesPerSecond = null;
+ DisarmStaleTimerUnlocked();
return progress with { BytesPerSecond = null };
}
if (bytes == LastThroughputBytes)
{
- // Stalled counter carries no new information: keep the previous speed instead
- // of synthesizing one, but move the baseline clock forward so the stalled
- // interval does not dilute the next real delta.
- LastThroughputSampleUtc = now;
+ // Stalled counter carries no new information: move the delta baseline
+ // forward so the stalled interval does not dilute the next real delta,
+ // but do not refresh freshness. Past the stale timeout the previous
+ // speed is dropped rather than displayed indefinitely.
+ LastThroughputTimestamp = now;
+ if (SmoothedBytesPerSecond is null)
+ {
+ return progress with { BytesPerSecond = null };
+ }
+
+ if (IsStaleUnlocked(now))
+ {
+ DisarmStaleTimerUnlocked();
+ return progress with { BytesPerSecond = null };
+ }
+
return progress with { BytesPerSecond = SmoothedBytesPerSecond };
}
- TimeSpan elapsed = now - LastThroughputSampleUtc;
+ TimeSpan elapsed = Stopwatch.GetElapsedTime(LastThroughputTimestamp, now);
if (elapsed <= TimeSpan.Zero)
{
// No time elapsed: preserve the previous speed without dividing by zero.
@@ -153,15 +209,202 @@ SmoothedBytesPerSecond is { } previous
SmoothedBytesPerSecond = smoothed;
LastThroughputBytes = bytes;
- LastThroughputSampleUtc = now;
+ LastThroughputTimestamp = now;
+ LastFreshTimestamp = now;
+ ArmStaleTimerUnlocked();
return progress with { BytesPerSecond = smoothed };
}
+ private bool IsStaleUnlocked(long now) =>
+ Stopwatch.GetElapsedTime(LastFreshTimestamp, now) > StaleSpeedTimeout;
+
+ private void ArmStaleTimerUnlocked()
+ {
+ try
+ {
+ if (StaleSpeedTimer is null)
+ {
+ StaleSpeedTimer = new Timer(
+ OnStaleSpeedTimer,
+ null,
+ StaleSpeedTimeout,
+ Timeout.InfiniteTimeSpan
+ );
+ }
+ else
+ {
+ StaleSpeedTimer.Change(StaleSpeedTimeout, Timeout.InfiniteTimeSpan);
+ }
+
+ StaleTimerArmed = true;
+ }
+ catch (Exception ex)
+ {
+ // Timer creation must never affect package execution.
+ Logger.Warn($"Could not arm the stale-speed timer; speed expiry is disabled for this report: {ex}");
+ StaleTimerArmed = false;
+ }
+ }
+
+ private void DisarmStaleTimerUnlocked()
+ {
+ StaleTimerArmed = false;
+ try
+ {
+ StaleSpeedTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Could not disarm the stale-speed timer: {ex}");
+ }
+ }
+
+ private void OnStaleSpeedTimer(object? state)
+ {
+ OperationProgress? expired = null;
+ try
+ {
+ long now = TimestampProvider();
+ lock (ProgressLock)
+ {
+ if (StaleSpeedTimer is null || !StaleTimerArmed)
+ {
+ return;
+ }
+
+ // Completion stops the freshness mechanism: terminal operations keep
+ // their final visuals and must not receive post-completion updates.
+ if (Status is not OperationStatus.Running)
+ {
+ DisarmStaleTimerUnlocked();
+ return;
+ }
+
+ if (!HasThroughputBaseline || SmoothedBytesPerSecond is null)
+ {
+ DisarmStaleTimerUnlocked();
+ return;
+ }
+
+ if (!IsStaleUnlocked(now))
+ {
+ // Fired early (wall-clock vs monotonic skew or re-arm race):
+ // re-arm for the remaining freshness window, no dispatcher spam.
+ try
+ {
+ TimeSpan elapsed = Stopwatch.GetElapsedTime(LastFreshTimestamp, now);
+ TimeSpan remaining = StaleSpeedTimeout - elapsed;
+ if (remaining < TimeSpan.Zero)
+ {
+ remaining = TimeSpan.Zero;
+ }
+
+ StaleSpeedTimer.Change(remaining, Timeout.InfiniteTimeSpan);
+ }
+ catch (ObjectDisposedException)
+ {
+ StaleTimerArmed = false;
+ }
+
+ return;
+ }
+
+ if (CurrentProgress.BytesPerSecond is null)
+ {
+ DisarmStaleTimerUnlocked();
+ return;
+ }
+
+ expired = CurrentProgress with { BytesPerSecond = null };
+ CurrentProgress = expired;
+ DisarmStaleTimerUnlocked();
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Stale-speed expiry failed; keeping the last measured speed: {ex}");
+ return;
+ }
+
+ if (expired is not null)
+ {
+ NotifyProgressSubscribers(expired);
+ }
+ }
+
+ ///
+ /// Deterministic test hook for stale-speed expiry: runs the same staleness check
+ /// the timer performs, using the injected monotonic clock. Returns true when a
+ /// speed-less update was pushed.
+ ///
+ internal bool ExpireStaleSpeedForTests()
+ {
+ OperationProgress? expired = null;
+ long now = TimestampProvider();
+ lock (ProgressLock)
+ {
+ if (!HasThroughputBaseline || SmoothedBytesPerSecond is null)
+ {
+ return false;
+ }
+
+ if (!IsStaleUnlocked(now))
+ {
+ return false;
+ }
+
+ if (CurrentProgress.BytesPerSecond is null)
+ {
+ DisarmStaleTimerUnlocked();
+ return false;
+ }
+
+ expired = CurrentProgress with { BytesPerSecond = null };
+ CurrentProgress = expired;
+ DisarmStaleTimerUnlocked();
+ }
+
+ NotifyProgressSubscribers(expired);
+ return true;
+ }
+
+ internal bool IsStaleSpeedTimerArmedForTests()
+ {
+ lock (ProgressLock)
+ {
+ return StaleTimerArmed;
+ }
+ }
+
private void ResetThroughputStateUnlocked()
{
HasThroughputBaseline = false;
LastThroughputBytes = 0;
- LastThroughputSampleUtc = default;
+ LastThroughputTimestamp = 0;
+ LastFreshTimestamp = 0;
SmoothedBytesPerSecond = null;
}
+
+ internal void DisposeStaleSpeedTimer()
+ {
+ Timer? timer;
+ lock (ProgressLock)
+ {
+ timer = StaleSpeedTimer;
+ StaleSpeedTimer = null;
+ StaleTimerArmed = false;
+ }
+
+ try
+ {
+ timer?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Could not dispose the stale-speed timer: {ex}");
+ }
+ }
}
diff --git a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs
index 7890f1bcd2..48d66b959e 100644
--- a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs
+++ b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs
@@ -47,6 +47,13 @@ public override Task GetOperationIcon()
protected override void ApplyRetryAction(string retryMode) { }
+ ///
+ /// Creates the HTTP client for the download. Virtual so tests can serve an
+ /// in-memory payload without standing up a loopback server.
+ ///
+ protected virtual HttpClient CreateHttpClient() =>
+ new(CoreTools.GenericHttpClientParameters);
+
protected override async Task PerformOperation()
{
bool downloadFileCreated = false;
@@ -87,7 +94,7 @@ protected override async Task PerformOperation()
}
Line($"Download URL found at {downloadUrl} ", LineType.Information);
- using var httpClient = new HttpClient(CoreTools.GenericHttpClientParameters);
+ using var httpClient = CreateHttpClient();
using var response = await httpClient.GetAsync(
downloadUrl,
HttpCompletionOption.ResponseHeadersRead,
@@ -122,12 +129,15 @@ protected override async Task PerformOperation()
if (canReportProgress)
{
var progress = (int)((totalRead * 100L) / totalBytes);
- ReportProgress(
- OperationProgress.FromDownload((ulong)totalRead, (ulong)totalBytes)
- );
if (progress != oldProgress)
{
oldProgress = progress;
+ ReportProgress(
+ OperationProgress.FromDownload(
+ (ulong)totalRead,
+ (ulong)totalBytes
+ )
+ );
Line(
CoreTools.TextProgressGenerator(
30,
diff --git a/src/UniGetUI.PackageEngine.Operations/OperationCardController.cs b/src/UniGetUI.PackageEngine.Operations/OperationCardController.cs
new file mode 100644
index 0000000000..5598e87ff8
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Operations/OperationCardController.cs
@@ -0,0 +1,116 @@
+using UniGetUI.PackageEngine.Enums;
+
+namespace UniGetUI.PackageOperations;
+
+///
+/// UI-thread-owned state machine behind OperationViewModel progress visuals.
+/// Owns the three pieces of display state that decide what the card shows:
+/// the pure mapping (), the last log-driven
+/// line for retry/reset restoration, and determinate ownership gating for raw
+/// per-frame progress text. All methods run on the UI thread in production
+/// (via Dispatcher.UIThread.Post, which is FIFO at the same priority, so the
+/// failure path clears ownership before the failure line arrives) and synchronously
+/// in unit tests.
+///
+public sealed class OperationCardController
+{
+ private OperationCardProgressState _card = new(
+ IsIndeterminate: false,
+ Value: 0,
+ LiveLine: ""
+ );
+
+ private string _lastLogLine = "";
+ private bool _determinateProgressActive;
+
+ public OperationCardProgressState Card => _card;
+ public string LastLogLine => _lastLogLine;
+ public bool DeterminateProgressActive => _determinateProgressActive;
+
+ ///
+ /// Initializes all three display states from the same current operation snapshot.
+ /// Must derive the determinate gate from so a
+ /// card constructed mid-download does not let raw progress lines clobber the
+ /// formatted line until the next report arrives.
+ ///
+ public void SyncInitial(
+ string initialLiveLine,
+ OperationStatus status,
+ OperationProgress? currentProgress
+ )
+ {
+ _card = _card with { LiveLine = initialLiveLine };
+ _lastLogLine = initialLiveLine;
+ ApplyStatus(status);
+ _card = _card.WithProgress(status, currentProgress);
+ _determinateProgressActive =
+ status is OperationStatus.Running && currentProgress?.IsDeterminate is true;
+ }
+
+ ///
+ /// Applies a log line to the card. Returns false when a raw per-frame progress
+ /// line is gated out while determinate progress owns the status line; otherwise
+ /// updates the card and last log line and returns the display line.
+ ///
+ public bool TryApplyLogLine(
+ string text,
+ AbstractOperation.LineType type,
+ out string liveLine
+ )
+ {
+ if (
+ type is AbstractOperation.LineType.ProgressIndicator
+ && _determinateProgressActive
+ )
+ {
+ liveLine = _card.LiveLine;
+ return false;
+ }
+
+ _card = _card with { LiveLine = text };
+ _lastLogLine = text;
+ liveLine = text;
+ return true;
+ }
+
+ ///
+ /// Applies a structured progress report. Returns the display values the ViewModel
+ /// copies onto its bindable properties. A plain Unknown reset restores the
+ /// last log line so stale speed-bearing text never survives retry/restart.
+ ///
+ public (bool IsIndeterminate, double Value, string LiveLine) ApplyProgress(
+ OperationStatus status,
+ OperationProgress? progress
+ )
+ {
+ _card = _card.WithProgress(status, progress);
+ _determinateProgressActive =
+ status is OperationStatus.Running && progress?.IsDeterminate is true;
+
+ string liveLine =
+ progress is null || progress.Stage is OperationProgressStage.Unknown
+ ? _lastLogLine
+ : _card.LiveLine;
+
+ // Keep the card's LiveLine in sync with what is actually displayed when the
+ // reset path restores the log line; the pure mapping intentionally keeps its
+ // held line, but the card must not show stale determinate text.
+ if (progress is null || progress.Stage is OperationProgressStage.Unknown)
+ {
+ _card = _card with { LiveLine = liveLine };
+ }
+
+ return (_card.IsIndeterminate, _card.Value, liveLine);
+ }
+
+ ///
+ /// Applies a status transition to the progress visuals. Determinate ownership ends
+ /// with the running phase; afterwards log lines own the status line again.
+ ///
+ public void ApplyStatus(OperationStatus status)
+ {
+ _card = _card.WithStatus(status);
+ _determinateProgressActive =
+ status is OperationStatus.Running && _determinateProgressActive;
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Operations/OperationCardProgressState.cs b/src/UniGetUI.PackageEngine.Operations/OperationCardProgressState.cs
index e57da8b710..c2a83277ce 100644
--- a/src/UniGetUI.PackageEngine.Operations/OperationCardProgressState.cs
+++ b/src/UniGetUI.PackageEngine.Operations/OperationCardProgressState.cs
@@ -5,9 +5,12 @@ namespace UniGetUI.PackageOperations;
///
/// Pure, UI-framework-agnostic mapping from operation status plus generic
/// to operation-card progress visuals.
-/// Extracted from OperationViewModel so the determinate/indeterminate contract is
-/// unit-testable without Avalonia. This type never touches the dispatcher or any UI
-/// control; the ViewModel remains the only UI-thread owner and copies
+/// This is one input to the card visuals, not the full source of truth:
+/// OperationViewModel additionally owns log-line restoration
+/// (_lastLogLine), determinate ownership gating
+/// (_determinateProgressActive), and dispatcher ordering, which are covered
+/// by ViewModel state-machine tests. This type never touches the dispatcher or any
+/// UI control; the ViewModel remains the only UI-thread owner and copies
/// , , and onto
/// its bindable properties.
///
diff --git a/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs b/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs
index b664b67181..4318f87012 100644
--- a/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs
+++ b/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs
@@ -6,11 +6,11 @@ namespace UniGetUI.PackageOperations;
///
/// Formats a generic for operation cards, log lines,
/// and screen-reader status. Unknown progress maps to a short stage label
-/// (indeterminate); determinate progress appends percent and, when available,
-/// human-readable byte counters plus the measured download throughput
-/// (e.g. "Downloading · 21% · 10.0 MB / 46.7 MB · 1.2 MB/s"). No ETA is shown.
-/// Size units reuse conventions; stage labels go
-/// through like every other user-facing string.
+/// (indeterminate); determinate progress uses translatable positional templates so
+/// translators control ordering, separators, and placement (e.g.
+/// "{0} · {1}% · {2} / {3} · {4}"). No ETA is shown.
+/// Size units reuse conventions; stage labels and
+/// the composed templates go through .
///
public static class OperationProgressFormatter
{
@@ -28,13 +28,31 @@ public static string Format(OperationProgress progress)
&& progress.BytesTotal.Value > 0
)
{
- string text =
- $"{label} · {percent}% · {FormatBytes(progress.BytesDownloaded.Value)} / {FormatBytes(progress.BytesTotal.Value)}";
+ string downloaded = CoreTools.FormatAsSize((long)progress.BytesDownloaded.Value);
+ string total = CoreTools.FormatAsSize((long)progress.BytesTotal.Value);
string? throughput = FormatThroughput(progress.BytesPerSecond);
- return throughput is null ? text : $"{text} · {throughput}";
+ if (throughput is null)
+ {
+ return CoreTools.Translate(
+ "{0} · {1}% · {2} / {3}",
+ label,
+ percent,
+ downloaded,
+ total
+ );
+ }
+
+ return CoreTools.Translate(
+ "{0} · {1}% · {2} / {3} · {4}",
+ label,
+ percent,
+ downloaded,
+ total,
+ throughput
+ );
}
- return $"{label} · {percent}%";
+ return CoreTools.Translate("{0} · {1}%", label, percent);
}
public static string StageLabel(OperationProgressStage stage) =>
@@ -50,18 +68,16 @@ public static string StageLabel(OperationProgressStage stage) =>
private static string IndeterminateLabel(OperationProgressStage stage) =>
stage switch
{
- OperationProgressStage.Downloading => CoreTools.Translate("Downloading..."),
- OperationProgressStage.Installing => CoreTools.Translate("Installing..."),
- OperationProgressStage.Updating => CoreTools.Translate("Updating..."),
- OperationProgressStage.Uninstalling => CoreTools.Translate("Uninstalling..."),
+ OperationProgressStage.Downloading
+ or OperationProgressStage.Installing
+ or OperationProgressStage.Updating
+ or OperationProgressStage.Uninstalling => CoreTools.Translate(
+ "{0}...",
+ StageLabel(stage)
+ ),
_ => CoreTools.Translate("Please wait..."),
};
- private static string FormatBytes(ulong value) =>
- value > (ulong)long.MaxValue
- ? $"{value / 1099511627776.0:F1} TB"
- : CoreTools.FormatAsSize((long)value);
-
///
/// Formats a measured throughput reusing units
/// with a "/s" suffix. Returns null when there is no usable speed, in which case the
diff --git a/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs b/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs
new file mode 100644
index 0000000000..ee06d8201c
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs
@@ -0,0 +1,226 @@
+using System.Diagnostics;
+using System.Net;
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageEngine.Interfaces;
+using UniGetUI.PackageEngine.Operations;
+using UniGetUI.PackageEngine.Tests.Infrastructure.Builders;
+using UniGetUI.PackageOperations;
+
+namespace UniGetUI.PackageEngine.Tests;
+
+///
+/// Proves reports bounded determinate progress:
+/// one structured report per integer percent (not per socket read), with 0→100
+/// propagation and usable throughput under the reduced sampling rate.
+/// Uses an in-memory HTTP handler so no loopback server is needed.
+///
+public sealed class DownloadOperationProgressTests
+{
+ private sealed class ManualTimestampClock
+ {
+ private long _ticks;
+
+ public long Now() => _ticks;
+
+ public void Advance(TimeSpan delta) =>
+ _ticks += (long)(delta.TotalSeconds * Stopwatch.Frequency);
+ }
+
+ private sealed class FragmentedReadStream(byte[] payload, int maxChunk, ManualTimestampClock? clock, TimeSpan perRead)
+ : MemoryStream(payload, writable: false)
+ {
+ private readonly ManualTimestampClock? _clock = clock;
+ public int ReadCalls;
+
+ public override async ValueTask ReadAsync(
+ Memory buffer,
+ CancellationToken cancellationToken = default
+ )
+ {
+ int count = await base.ReadAsync(
+ buffer[..Math.Min(buffer.Length, maxChunk)],
+ cancellationToken
+ );
+ if (count > 0)
+ {
+ ReadCalls++;
+ _clock?.Advance(perRead);
+ }
+
+ return count;
+ }
+ }
+
+ private sealed class FakeDownloadHandler(
+ byte[] payload,
+ int maxChunk,
+ ManualTimestampClock? clock,
+ TimeSpan perRead,
+ FragmentedReadStream? streamSink
+ ) : HttpMessageHandler
+ {
+ public int StreamReadCalls => _stream?.ReadCalls ?? 0;
+ private FragmentedReadStream? _stream = streamSink;
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken
+ )
+ {
+ var stream = new FragmentedReadStream(payload, maxChunk, clock, perRead);
+ _stream = stream;
+ var content = new StreamContent(stream);
+ content.Headers.ContentLength = payload.Length;
+ var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
+ return Task.FromResult(response);
+ }
+ }
+
+ private sealed class ProbeDownloadOperation(
+ IPackage package,
+ string downloadPath,
+ HttpMessageHandler handler
+ ) : DownloadOperation(package, downloadPath)
+ {
+ private readonly HttpMessageHandler _handler = handler;
+
+ protected override HttpClient CreateHttpClient() =>
+ new(_handler, disposeHandler: false);
+
+ public Task InvokePerformOperationForTests() =>
+ PerformOperation();
+ }
+
+ private static (IPackage Package, ManualTimestampClock Clock) CreatePackage(
+ out ManualTimestampClock clock
+ )
+ {
+ clock = new ManualTimestampClock();
+ var manager = new PackageManagerBuilder()
+ .ConfigureDetails(helper =>
+ {
+ helper.PopulateDetails = details =>
+ {
+ details.InstallerUrl = new Uri("http://127.0.0.1/payload.bin");
+ details.InstallerType = "exe";
+ };
+ })
+ .Build();
+ IPackage package = new PackageBuilder().WithManager(manager).Build();
+ return (package, clock);
+ }
+
+ [Fact]
+ public async Task ProgressEvents_BoundedByIntegerPercent_NotPerRead()
+ {
+ const int TotalBytes = 1024 * 1024;
+ const int ChunkBytes = 1024;
+ byte[] payload = new byte[TotalBytes];
+ new Random(42).NextBytes(payload);
+
+ var (package, clock) = CreatePackage(out _);
+ var handler = new FakeDownloadHandler(payload, ChunkBytes, clock, TimeSpan.FromMilliseconds(5), null);
+ string downloadPath = Path.Join(
+ Path.GetTempPath(),
+ $"unigetui-bounded-{Guid.NewGuid():N}.bin"
+ );
+ try
+ {
+ using var operation = new ProbeDownloadOperation(package, downloadPath, handler);
+ operation.SetTimestampProviderForTests(clock.Now);
+ var seen = new List();
+ operation.ProgressChanged += (_, p) => seen.Add(p);
+
+ OperationVeredict verdict = await operation.InvokePerformOperationForTests();
+
+ Assert.Equal(OperationVeredict.Success, verdict);
+ int readCalls = handler.StreamReadCalls;
+ Assert.True(readCalls > 500, $"expected many small reads, got {readCalls}");
+
+ // One stage marker + at most one report per integer percent.
+ var determinate = seen.Where(static p => p.IsDeterminate).ToArray();
+ Assert.True(seen.Count <= 102, $"expected bounded reports, got {seen.Count}");
+ Assert.True(seen.Count < readCalls);
+ Assert.Equal(
+ determinate.Select(static p => Math.Round(p.Percentage!.Value)).Distinct().Count(),
+ determinate.Length
+ );
+ }
+ finally
+ {
+ if (File.Exists(downloadPath))
+ File.Delete(downloadPath);
+ }
+ }
+
+ [Fact]
+ public async Task ProgressEvents_PropagateZeroAndHundred()
+ {
+ const int TotalBytes = 1024 * 1024;
+ byte[] payload = new byte[TotalBytes];
+ new Random(7).NextBytes(payload);
+
+ var (package, clock) = CreatePackage(out _);
+ var handler = new FakeDownloadHandler(payload, 1024, clock, TimeSpan.FromMilliseconds(5), null);
+ string downloadPath = Path.Join(
+ Path.GetTempPath(),
+ $"unigetui-zero-hundred-{Guid.NewGuid():N}.bin"
+ );
+ try
+ {
+ using var operation = new ProbeDownloadOperation(package, downloadPath, handler);
+ operation.SetTimestampProviderForTests(clock.Now);
+ var seen = new List();
+ operation.ProgressChanged += (_, p) => seen.Add(p);
+
+ Assert.Equal(
+ OperationVeredict.Success,
+ await operation.InvokePerformOperationForTests()
+ );
+
+ var determinate = seen.Where(static p => p.IsDeterminate).ToArray();
+ Assert.NotEmpty(determinate);
+ Assert.Equal(0, Math.Round(determinate.First().Percentage!.Value));
+ Assert.Equal(100, Math.Round(determinate.Last().Percentage!.Value));
+ Assert.Equal(100, Math.Round(operation.CurrentProgress.Percentage!.Value));
+ }
+ finally
+ {
+ if (File.Exists(downloadPath))
+ File.Delete(downloadPath);
+ }
+ }
+
+ [Fact]
+ public async Task Throughput_RemainsUsable_UnderReducedSampling()
+ {
+ const int TotalBytes = 1024 * 1024;
+ byte[] payload = new byte[TotalBytes];
+ new Random(11).NextBytes(payload);
+
+ var (package, clock) = CreatePackage(out _);
+ var handler = new FakeDownloadHandler(payload, 1024, clock, TimeSpan.FromMilliseconds(5), null);
+ string downloadPath = Path.Join(
+ Path.GetTempPath(),
+ $"unigetui-throughput-{Guid.NewGuid():N}.bin"
+ );
+ try
+ {
+ using var operation = new ProbeDownloadOperation(package, downloadPath, handler);
+ operation.SetTimestampProviderForTests(clock.Now);
+
+ Assert.Equal(
+ OperationVeredict.Success,
+ await operation.InvokePerformOperationForTests()
+ );
+
+ Assert.True(operation.CurrentProgress.HasThroughput);
+ Assert.Contains("/s", OperationProgressFormatter.Format(operation.CurrentProgress));
+ }
+ finally
+ {
+ if (File.Exists(downloadPath))
+ File.Delete(downloadPath);
+ }
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Tests/DownloadOperationThroughputTests.cs b/src/UniGetUI.PackageEngine.Tests/DownloadOperationThroughputTests.cs
deleted file mode 100644
index a5538ff4e7..0000000000
--- a/src/UniGetUI.PackageEngine.Tests/DownloadOperationThroughputTests.cs
+++ /dev/null
@@ -1,170 +0,0 @@
-using System.Net;
-using System.Net.Sockets;
-using System.Text;
-using UniGetUI.PackageEngine.Enums;
-using UniGetUI.PackageEngine.Interfaces;
-using UniGetUI.PackageEngine.Operations;
-using UniGetUI.PackageEngine.Tests.Infrastructure.Builders;
-using UniGetUI.PackageOperations;
-
-namespace UniGetUI.PackageEngine.Tests;
-
-///
-/// Proves the HTTP path surfaces measured download
-/// progress through the generic operation layer: a throttled loopback server streams a
-/// real payload with Content-Length, and at least one structured report must
-/// carry a finite positive BytesPerSecond derived from real bytes over real
-/// time. No synthetic speeds, no CLI parsing.
-///
-public sealed class DownloadOperationThroughputTests
-{
- private sealed class ProbeDownloadOperation(IPackage package, string downloadPath)
- : DownloadOperation(package, downloadPath)
- {
- public Task InvokePerformOperationForTests() =>
- PerformOperation();
- }
-
- ///
- /// Minimal throttled HTTP/1.1 server over loopback TCP: serves one fixed payload
- /// with Content-Length, pacing chunks so the client's real clock observes
- /// distinct progress samples with measurable throughput.
- ///
- private sealed class ThrottledLoopbackServer : IDisposable
- {
- private readonly TcpListener _listener;
- private readonly byte[] _payload;
- private readonly int _chunkSize;
- private readonly TimeSpan _chunkDelay;
- private readonly Task _serveTask;
-
- public Uri Url { get; }
-
- public ThrottledLoopbackServer(int totalBytes, int chunkSize, TimeSpan chunkDelay)
- {
- _payload = new byte[totalBytes];
- new Random(42).NextBytes(_payload);
- _chunkSize = chunkSize;
- _chunkDelay = chunkDelay;
- _listener = new TcpListener(IPAddress.Loopback, 0);
- _listener.Start();
- Url = new Uri(
- $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/payload.bin"
- );
- _serveTask = Task.Run(ServeOnceAsync);
- }
-
- private async Task ServeOnceAsync()
- {
- using TcpClient client = await _listener.AcceptTcpClientAsync();
- using NetworkStream stream = client.GetStream();
-
- // Consume the request headers.
- var request = new byte[4096];
- int seen = 0;
- while (seen < request.Length - 1)
- {
- int read = await stream.ReadAsync(request.AsMemory(seen));
- if (read == 0)
- break;
- seen += read;
- if (Encoding.ASCII.GetString(request, 0, seen).Contains("\r\n\r\n"))
- break;
- }
-
- string header =
- $"HTTP/1.1 200 OK\r\nContent-Length: {_payload.Length}\r\n"
- + "Content-Type: application/octet-stream\r\nConnection: close\r\n\r\n";
- byte[] headerBytes = Encoding.ASCII.GetBytes(header);
- await stream.WriteAsync(headerBytes);
- await stream.FlushAsync();
-
- for (int offset = 0; offset < _payload.Length; offset += _chunkSize)
- {
- int count = Math.Min(_chunkSize, _payload.Length - offset);
- await stream.WriteAsync(_payload.AsMemory(offset, count));
- await stream.FlushAsync();
- await Task.Delay(_chunkDelay);
- }
- }
-
- public void Dispose()
- {
- try
- {
- _serveTask.Wait(TimeSpan.FromSeconds(30));
- }
- catch
- {
- // Best-effort: a failed transfer still ends the test via its verdict.
- }
- _listener.Stop();
- }
- }
-
- [Fact]
- public async Task HttpDownload_ReportsMeasuredThroughput()
- {
- const int TotalBytes = 3 * 1024 * 1024;
- using var server = new ThrottledLoopbackServer(
- TotalBytes,
- chunkSize: 256 * 1024,
- chunkDelay: TimeSpan.FromMilliseconds(100)
- );
-
- var manager = new PackageManagerBuilder()
- .ConfigureDetails(helper =>
- {
- helper.PopulateDetails = details =>
- {
- details.InstallerUrl = server.Url;
- details.InstallerType = "exe";
- };
- })
- .Build();
- IPackage package = new PackageBuilder().WithManager(manager).Build();
-
- string downloadPath = Path.Join(
- Path.GetTempPath(),
- $"unigetui-throughput-{Guid.NewGuid():N}.bin"
- );
- try
- {
- using var operation = new ProbeDownloadOperation(package, downloadPath);
- var seenSpeeds = new List();
- operation.ProgressChanged += (_, progress) =>
- {
- lock (seenSpeeds)
- seenSpeeds.Add(progress.BytesPerSecond);
- };
-
- OperationVeredict verdict = await operation.InvokePerformOperationForTests();
-
- Assert.Equal(OperationVeredict.Success, verdict);
- Assert.Equal(TotalBytes, new FileInfo(downloadPath).Length);
-
- List speeds;
- lock (seenSpeeds)
- speeds = [.. seenSpeeds];
- Assert.NotEmpty(speeds);
- Assert.Contains(
- speeds,
- static speed =>
- speed.HasValue
- && !double.IsNaN(speed.Value)
- && !double.IsInfinity(speed.Value)
- && speed.Value > 0
- );
-
- // The enriched report formats with a live throughput suffix.
- OperationProgress last = operation.CurrentProgress;
- Assert.True(last.HasThroughput);
- Assert.Contains("/s", OperationProgressFormatter.Format(last));
- }
- finally
- {
- if (File.Exists(downloadPath))
- File.Delete(downloadPath);
- }
- }
-}
diff --git a/src/UniGetUI.PackageEngine.Tests/OperationCardControllerTests.cs b/src/UniGetUI.PackageEngine.Tests/OperationCardControllerTests.cs
new file mode 100644
index 0000000000..7ac9c1a545
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/OperationCardControllerTests.cs
@@ -0,0 +1,167 @@
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageOperations;
+
+namespace UniGetUI.PackageEngine.Tests;
+
+///
+/// Direct coverage for the actual card state machine behind
+/// OperationViewModel (_determinateProgressActive /
+/// _lastLogLine ordering). The controller is the exact logic the ViewModel
+/// delegates to on the UI thread, so these pin the display behavior without an
+/// Avalonia harness.
+///
+public sealed class OperationCardControllerTests
+{
+ private const ulong OneMiB = 1024UL * 1024;
+
+ private static OperationProgress DeterminateDownload(
+ ulong downloaded = 42,
+ ulong total = 100,
+ double? speed = 1024
+ ) =>
+ speed is null
+ ? OperationProgress.FromDownload(downloaded, total)
+ : OperationProgress.FromDownload(downloaded, total) with
+ {
+ BytesPerSecond = speed,
+ };
+
+ [Fact]
+ public void ConstructDuringDeterminate_ShowsFormatted_AndGatesRawLines()
+ {
+ var controller = new OperationCardController();
+ var current = DeterminateDownload(21 * OneMiB, 100 * OneMiB, 1.2 * OneMiB);
+
+ controller.SyncInitial("Starting operation...", OperationStatus.Running, current);
+ var (isIndeterminate, value, liveLine) = controller.ApplyProgress(
+ OperationStatus.Running,
+ current
+ );
+
+ Assert.False(isIndeterminate);
+ Assert.True(value > 0);
+ Assert.Contains("21%", liveLine);
+ Assert.True(controller.DeterminateProgressActive);
+
+ // A raw per-frame progress line must not clobber the formatted line.
+ Assert.False(
+ controller.TryApplyLogLine(
+ "[###] 21%",
+ AbstractOperation.LineType.ProgressIndicator,
+ out string afterRaw
+ )
+ );
+ Assert.Contains("21%", afterRaw);
+ Assert.Contains("/s", afterRaw);
+ }
+
+ [Fact]
+ public void FailureOrdering_ClearsGate_BeforeFailureLineArrives()
+ {
+ var controller = new OperationCardController();
+ controller.SyncInitial("Starting operation...", OperationStatus.Running, null);
+ controller.ApplyProgress(
+ OperationStatus.Running,
+ DeterminateDownload(40, 100, 1024)
+ );
+ Assert.True(controller.DeterminateProgressActive);
+
+ // Dispatcher FIFO: Status=Failed is handled before the failure
+ // ProgressIndicator line, so the gate is cleared first.
+ controller.ApplyStatus(OperationStatus.Failed);
+ Assert.False(controller.DeterminateProgressActive);
+
+ Assert.True(
+ controller.TryApplyLogLine(
+ "Failure message - Click here for more details",
+ AbstractOperation.LineType.ProgressIndicator,
+ out string liveLine
+ )
+ );
+ Assert.Contains("Failure message", liveLine);
+ }
+
+ [Fact]
+ public void RetryReset_RestoresLogLine_WithoutStaleSpeed()
+ {
+ var controller = new OperationCardController();
+ controller.SyncInitial("Starting operation...", OperationStatus.Running, null);
+ Assert.True(
+ controller.TryApplyLogLine(
+ "Starting operation...",
+ AbstractOperation.LineType.Information,
+ out _
+ )
+ );
+ controller.ApplyProgress(
+ OperationStatus.Running,
+ DeterminateDownload(40, 100, 1024)
+ );
+ Assert.Contains("/s", controller.Card.LiveLine);
+
+ // Plain Unknown reset (retry/restart) restores the last log line.
+ var (_, _, liveLine) = controller.ApplyProgress(
+ OperationStatus.Running,
+ OperationProgress.Unknown
+ );
+
+ Assert.Equal("Starting operation...", liveLine);
+ Assert.DoesNotContain("/s", liveLine);
+ Assert.False(controller.DeterminateProgressActive);
+ }
+
+ [Fact]
+ public void DownloadToStage_RemovesSpeed()
+ {
+ var controller = new OperationCardController();
+ controller.SyncInitial("Starting operation...", OperationStatus.Running, null);
+ var (_, _, determinateLine) = controller.ApplyProgress(
+ OperationStatus.Running,
+ DeterminateDownload(21 * OneMiB, 100 * OneMiB, 1.2 * OneMiB)
+ );
+ Assert.Contains("/s", determinateLine);
+
+ var (isIndeterminate, _, stageLine) = controller.ApplyProgress(
+ OperationStatus.Running,
+ OperationProgress.ForStage(OperationProgressStage.Installing)
+ );
+
+ Assert.True(isIndeterminate);
+ Assert.Contains("Installing", stageLine);
+ Assert.DoesNotContain("/s", stageLine);
+ Assert.False(controller.DeterminateProgressActive);
+ }
+
+ [Fact]
+ public void NonDeterminateLogLines_Flow_WhenNoDeterminateActive()
+ {
+ var controller = new OperationCardController();
+ controller.SyncInitial("Queued...", OperationStatus.InQueue, null);
+
+ Assert.True(
+ controller.TryApplyLogLine(
+ "Operation on queue (position 1)...",
+ AbstractOperation.LineType.ProgressIndicator,
+ out string liveLine
+ )
+ );
+ Assert.Contains("position 1", liveLine);
+ }
+
+ [Fact]
+ public void TerminalStatus_ClearsGate()
+ {
+ var controller = new OperationCardController();
+ controller.SyncInitial("Starting operation...", OperationStatus.Running, null);
+ controller.ApplyProgress(
+ OperationStatus.Running,
+ DeterminateDownload(40, 100, 1024)
+ );
+
+ controller.ApplyStatus(OperationStatus.Succeeded);
+
+ Assert.False(controller.DeterminateProgressActive);
+ Assert.False(controller.Card.IsIndeterminate);
+ Assert.Equal(100, controller.Card.Value);
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs b/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs
index dd1f71940c..ee54aa151d 100644
--- a/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs
+++ b/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs
@@ -4,16 +4,13 @@
namespace UniGetUI.PackageEngine.Tests;
///
-/// Direct coverage for the exact mapping uses for its
-/// operation card (indeterminate vs determinate, percent, byte/status text, retry/reset,
-/// terminal visuals). The mapping lives in so
-/// it runs without Avalonia; the ViewModel only copies the result onto bindable
-/// properties on the UI thread via Dispatcher.UIThread.Post.
+/// Small representative coverage for the pure card mapping. The full display
+/// behavior (log restoration, determinate gating, dispatcher ordering) lives in
+/// and is covered by
+/// OperationCardControllerTests.
///
public sealed class OperationCardProgressStateTests
{
- private const ulong OneMiB = 1024UL * 1024;
-
private static OperationCardProgressState FreshCard(string liveLine = "Please wait...") =>
new(IsIndeterminate: false, Value: 0, LiveLine: liveLine);
@@ -21,7 +18,7 @@ private static OperationCardProgressState RunningCard(string liveLine = "Please
FreshCard(liveLine).WithStatus(OperationStatus.Running);
[Fact]
- public void Running_WithNoProgress_StaysIndeterminate()
+ public void Running_Unknown_StaysIndeterminate()
{
var card = RunningCard().WithProgress(OperationStatus.Running, null);
@@ -30,20 +27,7 @@ public void Running_WithNoProgress_StaysIndeterminate()
}
[Fact]
- public void Running_WithPlainUnknown_PreservesLogDrivenLine()
- {
- var card = RunningCard("Downloading installer...").WithProgress(
- OperationStatus.Running,
- OperationProgress.Unknown
- );
-
- Assert.True(card.IsIndeterminate);
- // Plain Unknown resets must not overwrite the log-driven line.
- Assert.Equal("Downloading installer...", card.LiveLine);
- }
-
- [Fact]
- public void Running_WithKnownDownload_IsDeterminateWithPercentAndBytes()
+ public void Running_DeterminateDownload_IsDeterminate()
{
var card = RunningCard().WithProgress(
OperationStatus.Running,
@@ -53,52 +37,10 @@ public void Running_WithKnownDownload_IsDeterminateWithPercentAndBytes()
Assert.False(card.IsIndeterminate);
Assert.Equal(50, card.Value);
Assert.Contains("50%", card.LiveLine);
- // Byte counters are shown when both sides are known.
- Assert.Contains("/", card.LiveLine);
- }
-
- [Fact]
- public void Running_WithZeroPercent_IsDeterminate()
- {
- var card = RunningCard().WithProgress(
- OperationStatus.Running,
- OperationProgress.FromDownload(0, 100)
- );
-
- Assert.False(card.IsIndeterminate);
- Assert.Equal(0, card.Value);
- Assert.Contains("0%", card.LiveLine);
- }
-
- [Fact]
- public void Running_DownloadWithSpeed_ShowsThroughputInLiveLine()
- {
- var progress = OperationProgress.FromDownload(21 * OneMiB, 100 * OneMiB)
- with
- {
- BytesPerSecond = 1.2 * OneMiB,
- };
- var card = RunningCard().WithProgress(OperationStatus.Running, progress);
-
- Assert.False(card.IsIndeterminate);
- Assert.Contains("21%", card.LiveLine);
- Assert.Contains("/s", card.LiveLine);
- }
-
- [Fact]
- public void Running_UnknownDownloadStage_ShowsDownloadingIndeterminate()
- {
- var card = RunningCard("Starting operation...").WithProgress(
- OperationStatus.Running,
- OperationProgress.ForStage(OperationProgressStage.Downloading)
- );
-
- Assert.True(card.IsIndeterminate);
- Assert.Contains("Downloading", card.LiveLine);
}
[Fact]
- public void Running_UnknownInstallStage_ShowsInstallingIndeterminate()
+ public void Running_StageOnly_IsIndeterminate()
{
var card = RunningCard().WithProgress(
OperationStatus.Running,
@@ -107,99 +49,6 @@ public void Running_UnknownInstallStage_ShowsInstallingIndeterminate()
Assert.True(card.IsIndeterminate);
Assert.Contains("Installing", card.LiveLine);
- Assert.DoesNotContain("%", card.LiveLine);
- }
-
- [Fact]
- public void Running_UnknownUpdateStage_ShowsUpdatingIndeterminate()
- {
- var card = RunningCard().WithProgress(
- OperationStatus.Running,
- OperationProgress.ForStage(OperationProgressStage.Updating)
- );
-
- Assert.True(card.IsIndeterminate);
- Assert.Contains("Updating", card.LiveLine);
- }
-
- [Fact]
- public void Running_UnknownUninstallStage_ShowsUninstallingIndeterminate()
- {
- var card = RunningCard().WithProgress(
- OperationStatus.Running,
- OperationProgress.ForStage(OperationProgressStage.Uninstalling)
- );
-
- Assert.True(card.IsIndeterminate);
- Assert.Contains("Uninstalling", card.LiveLine);
- }
-
- [Fact]
- public void DownloadingToInstalling_RemovesSpeed()
- {
- var progress = OperationProgress.FromDownload(21 * OneMiB, 100 * OneMiB)
- with
- {
- BytesPerSecond = 1.2 * OneMiB,
- };
- var card = RunningCard().WithProgress(OperationStatus.Running, progress);
-
- Assert.Contains("/s", card.LiveLine);
-
- // Stage change away from Downloading: speed must not survive.
- card = card.WithProgress(
- OperationStatus.Running,
- OperationProgress.ForStage(OperationProgressStage.Installing)
- );
-
- Assert.True(card.IsIndeterminate);
- Assert.Contains("Installing", card.LiveLine);
- Assert.DoesNotContain("/s", card.LiveLine);
- }
-
- [Fact]
- public void RetryReset_ReturnsToIndeterminate_KeepingLineForVmLogRestore()
- {
- var card = RunningCard("Starting operation...").WithProgress(
- OperationStatus.Running,
- OperationProgress.FromDownload(40, 100) with { BytesPerSecond = 1024 }
- );
- Assert.False(card.IsIndeterminate);
- string determinateLine = card.LiveLine;
-
- var reset = card.WithProgress(OperationStatus.Running, OperationProgress.Unknown);
-
- Assert.True(reset.IsIndeterminate);
- // The mapping never invents text: it keeps the line it holds. The ViewModel
- // swaps this for its separately-tracked last log line, so the stale
- // speed-bearing text never survives a retry reset on the real card.
- Assert.Equal(determinateLine, reset.LiveLine);
- }
-
- [Theory]
- [InlineData(OperationStatus.Succeeded)]
- [InlineData(OperationStatus.Failed)]
- [InlineData(OperationStatus.Canceled)]
- public void Progress_AfterTerminal_IsIgnored_StaleSpeedCannotSurvive(OperationStatus status)
- {
- // Terminal visuals own the card: a stale speed-bearing report arriving after
- // completion must not leak back into the visuals.
- var card = RunningCard()
- .WithProgress(
- OperationStatus.Running,
- OperationProgress.FromDownload(40, 100) with { BytesPerSecond = 1024 }
- )
- .WithStatus(status);
- var before = card;
-
- card = card.WithProgress(
- status,
- OperationProgress.FromDownload(90, 100) with { BytesPerSecond = 999_999 }
- );
-
- Assert.Equal(before, card);
- Assert.False(card.IsIndeterminate);
- Assert.Equal(100, card.Value);
}
[Theory]
@@ -213,30 +62,4 @@ public void TerminalStatus_OwnsFullBar(OperationStatus status)
Assert.False(card.IsIndeterminate);
Assert.Equal(100, card.Value);
}
-
- [Fact]
- public void InQueue_ResetsToZero()
- {
- var card = RunningCard()
- .WithProgress(
- OperationStatus.Running,
- OperationProgress.FromDownload(40, 100)
- )
- .WithStatus(OperationStatus.InQueue);
-
- Assert.False(card.IsIndeterminate);
- Assert.Equal(0, card.Value);
- }
-
- [Fact]
- public void OvershootPercentage_IsClampedOnCard()
- {
- var card = RunningCard().WithProgress(
- OperationStatus.Running,
- OperationProgress.FromDownload(150, 100)
- );
-
- Assert.False(card.IsIndeterminate);
- Assert.Equal(100, card.Value);
- }
}
diff --git a/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs b/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
index 4a7e114d13..059a47e51f 100644
--- a/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
+++ b/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using UniGetUI.PackageEngine.Enums;
using UniGetUI.PackageOperations;
using LineType = UniGetUI.PackageOperations.AbstractOperation.LineType;
@@ -5,14 +6,15 @@
namespace UniGetUI.PackageEngine.Tests;
///
-/// Covers the manager-neutral model and the generic
-/// throughput tracker in : determinate/unknown rules,
-/// single-clock speed measurement, EMA smoothing, reset semantics, thread safety, and
-/// the guarantee that structured progress never touches the log/history path.
+/// Covers the manager-neutral model, the generic
+/// monotonic throughput tracker, stale-speed expiry, and subscriber isolation.
+/// Download throttling lives in and is covered by
+/// DownloadOperationProgressTests; card visuals by
+/// OperationCardProgressStateTests and OperationCardControllerTests.
///
public sealed class OperationProgressTests
{
- private class ProgressProbeOperation : AbstractOperation
+ private sealed class ProgressProbeOperation : AbstractOperation
{
public ProgressProbeOperation()
: base(queue_enabled: false)
@@ -28,12 +30,10 @@ public ProgressProbeOperation()
public void ReportForTests(OperationProgress progress) => ReportProgress(progress);
- public void ResetForTests() => ResetProgress();
-
public void EmitForTests(string line, LineType type) => Line(line, type);
- public void SetClockForTests(Func provider) =>
- SetUtcNowProviderForTests(provider);
+ public void SetClockForTests(Func provider) =>
+ SetTimestampProviderForTests(provider);
protected override void ApplyRetryAction(string retryMode) { }
@@ -45,26 +45,26 @@ public override Task GetOperationIcon() =>
}
///
- /// Deterministic manual clock. Production uses DateTime.UtcNow via the default
- /// provider; tests advance time explicitly, which also proves the tracker honors
- /// the injected clock (a DateTime.UtcNow leak would break the frozen-clock tests).
+ /// Deterministic monotonic clock. Production uses Stopwatch.GetTimestamp();
+ /// tests advance explicitly by Stopwatch frequency ticks.
///
- private sealed class ManualClock
+ private sealed class ManualTimestampClock
{
- private DateTime _now = new(2026, 1, 12, 12, 0, 0, DateTimeKind.Utc);
+ private long _ticks;
- public DateTime Now() => _now;
+ public long Now() => _ticks;
- public void Advance(TimeSpan delta) => _now += delta;
+ public void Advance(TimeSpan delta) =>
+ _ticks += (long)(delta.TotalSeconds * Stopwatch.Frequency);
}
private const ulong OneMiB = 1024UL * 1024;
private const ulong TenMiB = 10UL * 1024 * 1024;
- private static (ProgressProbeOperation Op, ManualClock Clock) CreateClockedProbe()
+ private static (ProgressProbeOperation Op, ManualTimestampClock Clock) CreateClockedProbe()
{
var op = new ProgressProbeOperation();
- var clock = new ManualClock();
+ var clock = new ManualTimestampClock();
op.SetClockForTests(clock.Now);
return (op, clock);
}
@@ -96,24 +96,6 @@ public void FromDownload_Mid_IsDeterminateWithDerivedPercentage()
Assert.Equal(52, Math.Round(progress.Percentage!.Value));
}
- [Fact]
- public void FromDownload_ZeroBytes_IsDeterminateZero_NotUnknown()
- {
- var progress = OperationProgress.FromDownload(0, TenMiB);
-
- Assert.True(progress.IsDeterminate);
- Assert.Equal(0, progress.Percentage);
- }
-
- [Fact]
- public void FromDownload_Full_IsDeterminateHundred()
- {
- var progress = OperationProgress.FromDownload(TenMiB, TenMiB);
-
- Assert.True(progress.IsDeterminate);
- Assert.Equal(100, progress.Percentage);
- }
-
[Fact]
public void FromDownload_ZeroTotal_IsIndeterminate_NotFakeZero()
{
@@ -121,7 +103,6 @@ public void FromDownload_ZeroTotal_IsIndeterminate_NotFakeZero()
Assert.False(progress.IsDeterminate);
Assert.Null(progress.Percentage);
- Assert.Equal(OperationProgressStage.Downloading, progress.Stage);
}
[Fact]
@@ -132,84 +113,9 @@ public void FromDownload_Overshoot_ClampsPercentageKeepsRealBytes()
Assert.True(progress.IsDeterminate);
Assert.Equal(100, progress.Percentage);
Assert.Equal(150UL, progress.BytesDownloaded);
- Assert.Equal(100UL, progress.BytesTotal);
- }
-
- [Theory]
- [InlineData(34.0)]
- [InlineData(0.0)]
- [InlineData(100.0)]
- public void FromInstall_Known_IsDeterminate(double percent)
- {
- var progress = OperationProgress.FromInstall(percent);
-
- Assert.True(progress.IsDeterminate);
- Assert.Equal(percent, progress.Percentage);
- Assert.Equal(OperationProgressStage.Installing, progress.Stage);
- }
-
- [Theory]
- [InlineData(150.0)]
- public void FromInstall_AboveHundred_Clamps(double percent)
- {
- Assert.Equal(100, OperationProgress.FromInstall(percent).Percentage);
- }
-
- [Theory]
- [InlineData(null)]
- [InlineData(-1.0)]
- [InlineData(double.NaN)]
- [InlineData(double.PositiveInfinity)]
- [InlineData(double.NegativeInfinity)]
- public void FromInstall_Unknown_StaysIndeterminateWithoutFakePercent(double? percent)
- {
- var progress = OperationProgress.FromInstall(percent);
-
- Assert.False(progress.IsDeterminate);
- Assert.Null(progress.Percentage);
- Assert.Equal(OperationProgressStage.Installing, progress.Stage);
- }
-
- [Fact]
- public void FromUpdate_And_FromUninstall_CarryTheirStage()
- {
- Assert.Equal(
- OperationProgressStage.Updating,
- OperationProgress.FromUpdate(10).Stage
- );
- Assert.Equal(
- OperationProgressStage.Uninstalling,
- OperationProgress.FromUninstall(10).Stage
- );
- Assert.False(OperationProgress.FromUpdate(null).IsDeterminate);
- Assert.False(OperationProgress.FromUninstall(null).IsDeterminate);
- }
-
- [Theory]
- [InlineData(double.NaN)]
- [InlineData(double.PositiveInfinity)]
- [InlineData(double.NegativeInfinity)]
- [InlineData(0)]
- [InlineData(-12.5)]
- public void NormalizeBytesPerSecond_RejectsNonPositiveAndNonFinite(double value)
- {
- Assert.Null(OperationProgress.NormalizeBytesPerSecond(value));
- Assert.False(
- (OperationProgress.Unknown with { BytesPerSecond = value }).HasThroughput
- );
- }
-
- [Fact]
- public void NormalizeBytesPerSecond_KeepsPositiveFinite()
- {
- Assert.Equal(3.5, OperationProgress.NormalizeBytesPerSecond(3.5));
- Assert.True(
- (OperationProgress.Unknown with { BytesPerSecond = 3.5 }).HasThroughput
- );
- Assert.Null(OperationProgress.NormalizeBytesPerSecond(null));
}
- // ── Throughput: one clock, real bytes over real time ───────────────────
+ // ── Throughput: monotonic clock, real bytes over real time ─────────────
[Fact]
public void FirstDownloadSample_HasNoSpeed()
@@ -221,54 +127,61 @@ public void FirstDownloadSample_HasNoSpeed()
Assert.True(op.CurrentProgress.IsDeterminate);
Assert.Null(op.CurrentProgress.BytesPerSecond);
- Assert.False(op.CurrentProgress.HasThroughput);
}
}
[Fact]
- public void FrozenClock_SecondSample_HasNoSpeed_ProvesInjectedClockIsUsed()
+ public void SecondValidSample_CalculatesDeltaBytesOverDeltaTime()
{
- // The clock never advances: elapsed time is exactly zero. A DateTime.UtcNow
- // leak inside the tracker would observe real elapsed time and produce a
- // (huge, fake) speed; the injected clock correctly yields no speed.
- var (op, _) = CreateClockedProbe();
+ var (op, clock) = CreateClockedProbe();
using (op)
{
ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
ReportDownload(op, OneMiB);
- Assert.Null(op.CurrentProgress.BytesPerSecond);
- Assert.False(op.CurrentProgress.HasThroughput);
+ Assert.Equal((double)OneMiB, op.CurrentProgress.BytesPerSecond);
}
}
[Fact]
- public void SecondValidSample_CalculatesDeltaBytesOverDeltaTime()
+ public void ZeroTimeDelta_PreservesPreviousSpeedWithoutNaN()
{
var (op, clock) = CreateClockedProbe();
using (op)
{
ReportDownload(op, 0);
- clock.Advance(TimeSpan.FromSeconds(1));
ReportDownload(op, OneMiB);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
- Assert.Equal((double)OneMiB, op.CurrentProgress.BytesPerSecond);
- Assert.True(op.CurrentProgress.HasThroughput);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 2 * OneMiB);
+ double? speed = op.CurrentProgress.BytesPerSecond;
+ Assert.NotNull(speed);
+
+ ReportDownload(op, 3 * OneMiB);
+ Assert.Equal(speed, op.CurrentProgress.BytesPerSecond);
}
}
[Fact]
- public void SpeedDelta_IsMeasuredFromPreviousSample()
+ public void BackwardByteCounter_ResetsSpeedAndStartsNewBaseline()
{
var (op, clock) = CreateClockedProbe();
using (op)
{
- ReportDownload(op, OneMiB);
- clock.Advance(TimeSpan.FromSeconds(4));
- ReportDownload(op, 3 * OneMiB);
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 2 * OneMiB);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 512);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
- // (3 MiB - 1 MiB) / 4 s = 0.5 MiB/s.
- Assert.Equal((double)(OneMiB / 2), op.CurrentProgress.BytesPerSecond);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 512 + OneMiB);
+ Assert.Equal((double)OneMiB, op.CurrentProgress.BytesPerSecond);
}
}
@@ -282,9 +195,9 @@ public void Smoothing_IsDeterministicExponentialMovingAverage()
{
ReportDownload(op, 0);
clock.Advance(TimeSpan.FromSeconds(1));
- ReportDownload(op, OneMiB); // instant = 1 MiB/s
+ ReportDownload(op, OneMiB);
clock.Advance(TimeSpan.FromSeconds(1));
- ReportDownload(op, 3 * OneMiB); // instant = 2 MiB/s
+ ReportDownload(op, 3 * OneMiB);
return op.CurrentProgress.BytesPerSecond;
}
}
@@ -294,78 +207,69 @@ public void Smoothing_IsDeterministicExponentialMovingAverage()
Assert.NotNull(first);
Assert.Equal(first, second);
- // EMA with alpha 0.3: 0.3 * 2 MiB/s + 0.7 * 1 MiB/s = 1.3 MiB/s.
Assert.InRange(first!.Value, 1.3 * OneMiB - 1, 1.3 * OneMiB + 1);
}
[Fact]
- public void ZeroTimeDelta_PreservesPreviousSpeedWithoutNaN()
+ public void StageTransition_ResetsSpeed()
{
var (op, clock) = CreateClockedProbe();
using (op)
{
ReportDownload(op, 0);
- // Second sample at the very same timestamp: no speed yet, no NaN.
+ clock.Advance(TimeSpan.FromSeconds(1));
ReportDownload(op, OneMiB);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+
+ op.ReportForTests(OperationProgress.ForStage(OperationProgressStage.Installing));
Assert.Null(op.CurrentProgress.BytesPerSecond);
clock.Advance(TimeSpan.FromSeconds(1));
ReportDownload(op, 2 * OneMiB);
- double? speed = op.CurrentProgress.BytesPerSecond;
- Assert.NotNull(speed);
-
- // More bytes but no time elapsed: previous speed preserved, finite.
- ReportDownload(op, 3 * OneMiB);
- Assert.Equal(speed, op.CurrentProgress.BytesPerSecond);
- Assert.True(op.CurrentProgress.HasThroughput);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
}
}
+ // ── Stale speed expiry ─────────────────────────────────────────────────
+
[Fact]
- public void BackwardByteCounter_ResetsSpeedAndStartsNewBaseline()
+ public void FreshSpeed_IsVisible_AndArmsExpiryTimer()
{
var (op, clock) = CreateClockedProbe();
using (op)
{
ReportDownload(op, 0);
clock.Advance(TimeSpan.FromSeconds(1));
- ReportDownload(op, 2 * OneMiB);
- Assert.NotNull(op.CurrentProgress.BytesPerSecond);
-
- // Counter rewound (retry/restart): no stale speed survives.
- clock.Advance(TimeSpan.FromSeconds(1));
- ReportDownload(op, 512);
- Assert.Null(op.CurrentProgress.BytesPerSecond);
+ ReportDownload(op, OneMiB);
- // The rewound sample is the new baseline: next delta measures from it.
- clock.Advance(TimeSpan.FromSeconds(1));
- ReportDownload(op, 512 + OneMiB);
- Assert.Equal((double)OneMiB, op.CurrentProgress.BytesPerSecond);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+ Assert.True(op.IsStaleSpeedTimerArmedForTests());
}
}
[Fact]
- public void RepeatedByteCount_PreservesPreviousSpeed()
+ public void StaleSpeed_Disappears_AfterTimeout()
{
var (op, clock) = CreateClockedProbe();
using (op)
{
+ var seen = new List();
+ op.ProgressChanged += (_, p) => seen.Add(p);
+
ReportDownload(op, 0);
clock.Advance(TimeSpan.FromSeconds(1));
ReportDownload(op, OneMiB);
- double? speed = op.CurrentProgress.BytesPerSecond;
- Assert.NotNull(speed);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
- // Stalled counter carries no new information: keep the previous speed
- // instead of synthesizing a meaningless new one.
- clock.Advance(TimeSpan.FromSeconds(5));
- ReportDownload(op, OneMiB);
- Assert.Equal(speed, op.CurrentProgress.BytesPerSecond);
+ clock.Advance(TimeSpan.FromSeconds(3));
+ Assert.True(op.ExpireStaleSpeedForTests());
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ Assert.Contains(seen, static p => p.BytesPerSecond is null);
}
}
[Fact]
- public void StageTransition_ResetsSpeed()
+ public void FreshSample_AfterStale_RestoresSpeed()
{
var (op, clock) = CreateClockedProbe();
using (op)
@@ -373,22 +277,19 @@ public void StageTransition_ResetsSpeed()
ReportDownload(op, 0);
clock.Advance(TimeSpan.FromSeconds(1));
ReportDownload(op, OneMiB);
- Assert.NotNull(op.CurrentProgress.BytesPerSecond);
- // Download -> install: speed is stripped, never carried over.
- op.ReportForTests(OperationProgress.FromInstall(50));
+ clock.Advance(TimeSpan.FromSeconds(3));
+ Assert.True(op.ExpireStaleSpeedForTests());
Assert.Null(op.CurrentProgress.BytesPerSecond);
- Assert.False(op.CurrentProgress.HasThroughput);
- // A fresh download starts without a stale speed.
clock.Advance(TimeSpan.FromSeconds(1));
ReportDownload(op, 2 * OneMiB);
- Assert.Null(op.CurrentProgress.BytesPerSecond);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
}
}
[Fact]
- public void ResetProgress_ClearsSpeedAndReturnsToUnknown()
+ public void RepeatedCounter_WhenStale_DoesNotFabricateSpeed()
{
var (op, clock) = CreateClockedProbe();
using (op)
@@ -396,21 +297,23 @@ public void ResetProgress_ClearsSpeedAndReturnsToUnknown()
ReportDownload(op, 0);
clock.Advance(TimeSpan.FromSeconds(1));
ReportDownload(op, OneMiB);
- Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+ double? speed = op.CurrentProgress.BytesPerSecond;
+ Assert.NotNull(speed);
- op.ResetForTests();
- Assert.Equal(OperationProgress.Unknown, op.CurrentProgress);
- Assert.Null(op.CurrentProgress.BytesPerSecond);
+ // Fresh repeated counter preserves the previous speed.
+ clock.Advance(TimeSpan.FromMilliseconds(500));
+ ReportDownload(op, OneMiB);
+ Assert.Equal(speed, op.CurrentProgress.BytesPerSecond);
- // Same counters after a reset behave like a first sample again.
- clock.Advance(TimeSpan.FromSeconds(1));
+ // Stalled past the timeout drops the speed instead of preserving it.
+ clock.Advance(TimeSpan.FromSeconds(3));
ReportDownload(op, OneMiB);
Assert.Null(op.CurrentProgress.BytesPerSecond);
}
}
[Fact]
- public void UnknownProgress_ClearsSpeed()
+ public void StageReset_StopsExpiryMechanism()
{
var (op, clock) = CreateClockedProbe();
using (op)
@@ -418,89 +321,44 @@ public void UnknownProgress_ClearsSpeed()
ReportDownload(op, 0);
clock.Advance(TimeSpan.FromSeconds(1));
ReportDownload(op, OneMiB);
- Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+ Assert.True(op.IsStaleSpeedTimerArmedForTests());
- op.ReportForTests(OperationProgress.Unknown);
- Assert.Null(op.CurrentProgress.BytesPerSecond);
-
- clock.Advance(TimeSpan.FromSeconds(1));
- ReportDownload(op, 2 * OneMiB);
- Assert.Null(op.CurrentProgress.BytesPerSecond);
+ op.ReportForTests(OperationProgress.ForStage(OperationProgressStage.Installing));
+ Assert.False(op.IsStaleSpeedTimerArmedForTests());
+ Assert.False(op.ExpireStaleSpeedForTests());
}
}
- [Fact]
- public void NonDownloadingStages_NeverCarrySpeed()
- {
- var (op, _) = CreateClockedProbe();
- using (op)
- {
- // Even a hand-built installing report with speed is sanitized.
- op.ReportForTests(OperationProgress.FromInstall(50) with { BytesPerSecond = 999 });
- Assert.Null(op.CurrentProgress.BytesPerSecond);
-
- op.ReportForTests(OperationProgress.Unknown with { BytesPerSecond = 999 });
- Assert.Null(op.CurrentProgress.BytesPerSecond);
- }
- }
+ // ── Subscriber isolation: progress never fails the operation ───────────
[Fact]
- public void EveryReport_PropagatesExactlyOneEvent()
+ public void ThrowingSubscriber_DoesNotEscape_AndLaterSubscriberStillReceives()
{
using var op = new ProgressProbeOperation();
- int events = 0;
- op.ProgressChanged += (_, _) => events++;
+ bool secondReceived = false;
+ op.ProgressChanged += (_, _) => throw new InvalidOperationException("display bug");
+ op.ProgressChanged += (_, _) => secondReceived = true;
- // No throttling/coalescing: stage changes, determinate updates, and resets
- // all propagate immediately on the reporting thread.
- op.ReportForTests(OperationProgress.ForStage(OperationProgressStage.Downloading));
- op.ReportForTests(OperationProgress.FromDownload(50, 100));
- op.ResetForTests();
+ var progress = OperationProgress.FromDownload(50, 100);
+ var ex = Record.Exception(() => op.ReportForTests(progress));
- Assert.Equal(3, events);
+ Assert.Null(ex);
+ Assert.True(secondReceived);
+ Assert.Equal(50, op.CurrentProgress.Percentage);
}
[Fact]
- public async Task RapidConcurrentReports_AreSafeAndFinite()
+ public void ThrowingSubscriber_DoesNotTurnSuccessfulOperationIntoFailure()
{
using var op = new ProgressProbeOperation();
- var seenSpeeds = new System.Collections.Concurrent.ConcurrentBag();
- op.ProgressChanged += (_, progress) => seenSpeeds.Add(progress.BytesPerSecond);
-
- await Task.WhenAll(
- Enumerable
- .Range(0, 8)
- .Select(worker =>
- Task.Run(() =>
- {
- for (ulong step = 0; step < 50; step++)
- op.ReportForTests(
- OperationProgress.FromDownload(
- (ulong)worker * 1000 + step,
- 100_000
- )
- );
- })
- )
- );
+ op.ProgressChanged += (_, _) => throw new InvalidOperationException("display bug");
- foreach (double? speed in seenSpeeds)
- Assert.True(
- speed is null
- || (!double.IsNaN(speed.Value)
- && !double.IsInfinity(speed.Value)
- && speed.Value > 0),
- $"Non-finite speed leaked: {speed}"
- );
-
- OperationProgress current = op.CurrentProgress;
- Assert.True(current.IsDeterminate);
- Assert.True(
- current.BytesPerSecond is null
- || (!double.IsNaN(current.BytesPerSecond.Value)
- && !double.IsInfinity(current.BytesPerSecond.Value)
- && current.BytesPerSecond.Value > 0)
+ var ex = Record.Exception(() =>
+ op.ReportForTests(OperationProgress.FromDownload(10, 100))
);
+
+ Assert.Null(ex);
+ Assert.True(op.CurrentProgress.IsDeterminate);
}
// ── Separation: progress never touches log/history ─────────────────────
@@ -509,17 +367,15 @@ current.BytesPerSecond is null
public void ReportProgress_DoesNotWriteToOperationOutput()
{
using var op = new ProgressProbeOperation();
- var clock = new ManualClock();
+ var clock = new ManualTimestampClock();
op.SetClockForTests(clock.Now);
op.ReportForTests(OperationProgress.ForStage(OperationProgressStage.Downloading));
ReportDownload(op, OneMiB);
clock.Advance(TimeSpan.FromSeconds(1));
ReportDownload(op, 2 * OneMiB);
- op.ResetForTests();
+ op.ReportForTests(OperationProgress.Unknown);
- // The constructor only emits a ProgressIndicator line, which is excluded
- // from the output by design; structured reports add nothing at all.
Assert.Empty(op.GetOutput());
}
@@ -530,64 +386,15 @@ public void ProgressIndicatorLogLines_DoNotCreateStructuredProgress()
int progressEvents = 0;
op.ProgressChanged += (_, _) => progressEvents++;
- // Raw per-frame progress text flows through the normal log path only.
op.EmitForTests("[###.....] 30% (3.0 MB/10.0 MB)", LineType.ProgressIndicator);
op.EmitForTests("Fetching download url...", LineType.Information);
Assert.Equal(0, progressEvents);
Assert.Equal(OperationProgress.Unknown, op.CurrentProgress);
Assert.Single(op.GetOutput());
- Assert.Equal("Fetching download url...", op.GetOutput()[0].Item1);
}
- // ── Retry resets progress ──────────────────────────────────────────────
-
- private sealed class AutoRetryProbeOperation : ProgressProbeOperation
- {
- private int _attempts;
-
- protected override Task PerformOperation()
- {
- _attempts++;
- if (_attempts == 1)
- {
- // First attempt reports real progress, then asks for a retry.
- ReportProgress(OperationProgress.FromDownload(50, 100));
- return Task.FromResult(OperationVeredict.AutoRetry);
- }
-
- // Retry restarts observationally (as PackageOperation does per attempt).
- ReportProgress(OperationProgress.ForStage(OperationProgressStage.Downloading));
- return Task.FromResult(OperationVeredict.Success);
- }
- }
-
- [Fact]
- public async Task AutoRetry_AttemptBoundary_ResetsToUnknown()
- {
- using var op = new AutoRetryProbeOperation();
- var seen = new List();
- op.ProgressChanged += (_, p) => seen.Add(p);
-
- await op.MainThread();
-
- Assert.Equal(OperationStatus.Succeeded, op.Status);
- Assert.Contains(seen, static p => p is { IsDeterminate: true, Percentage: 50 });
- // The retry attempt restarts observationally with indeterminate progress and
- // no speed, after the determinate report of the first attempt.
- int determinateIndex = seen.FindIndex(
- static p => p is { IsDeterminate: true, Percentage: 50 }
- );
- Assert.True(determinateIndex >= 0);
- Assert.Contains(
- seen.Skip(determinateIndex + 1),
- static p => !p.IsDeterminate
- && p.Stage == OperationProgressStage.Downloading
- && p.BytesPerSecond is null
- );
- }
-
- // ── Formatter ──────────────────────────────────────────────────────────
+ // ── Formatter (small representative set) ───────────────────────────────
[Fact]
public void Formatter_DeterminateDownload_IncludesPercentAndByteCounters()
@@ -614,38 +421,13 @@ public void Formatter_DownloadWithSpeed_AppendsThroughput()
string text = OperationProgressFormatter.Format(progress);
Assert.Contains("21%", text);
- Assert.Contains("/", text);
Assert.Contains("/s", text);
- Assert.Contains("MB", text);
- }
-
- [Fact]
- public void Formatter_IndeterminateInstall_ShowsStageWithoutPercent()
- {
- string text = OperationProgressFormatter.Format(
- OperationProgress.FromInstall(null)
- );
-
- Assert.Contains("Installing", text);
- Assert.DoesNotContain("%", text);
}
[Fact]
- public void Formatter_Unknown_DoesNotThrow()
+ public void Formatter_StaleSpeed_IsOmitted()
{
- Assert.False(string.IsNullOrWhiteSpace(OperationProgressFormatter.Format(OperationProgress.Unknown)));
- }
-
- [Theory]
- [InlineData(double.NaN)]
- [InlineData(double.PositiveInfinity)]
- [InlineData(double.NegativeInfinity)]
- [InlineData(0)]
- [InlineData(-5)]
- public void Formatter_UnusableSpeed_IsOmitted(double bytesPerSecond)
- {
- var progress =
- OperationProgress.FromDownload(50, 100) with { BytesPerSecond = bytesPerSecond };
+ var progress = OperationProgress.FromDownload(50, 100) with { BytesPerSecond = (double?)null };
Assert.DoesNotContain("/s", OperationProgressFormatter.Format(progress));
}
diff --git a/src/UniGetUI.PackageEngine.Tests/WingetCliOutputProgressRegressionTests.cs b/src/UniGetUI.PackageEngine.Tests/WingetCliOutputProgressRegressionTests.cs
deleted file mode 100644
index ee5e655e87..0000000000
--- a/src/UniGetUI.PackageEngine.Tests/WingetCliOutputProgressRegressionTests.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-using UniGetUI.PackageEngine.Enums;
-using UniGetUI.PackageOperations;
-using LineType = UniGetUI.PackageOperations.AbstractOperation.LineType;
-
-namespace UniGetUI.PackageEngine.Tests;
-
-///
-/// Regression evidence for the §5 determination: piped WinGet CLI output carries no
-/// reliable progress frames, so it must flow through the normal log/history path only
-/// and must never produce structured (determinate) progress.
-///
-/// Fixtures are the sanitized lines actually captured from
-/// winget download --id 7zip.7zip --exact --accept-source-agreements
-/// --disable-interactivity --accept-package-agreements (winget v1.29.290) with
-/// stdout redirected: six plain CR LF lines over an ~8.5 s download, empty stderr,
-/// no ANSI escapes, no byte counters, no percentages. The test replays them exactly as
-/// splits them (CR-terminated text becomes a
-/// line, promoted to
-/// by the bare LF that follows) and asserts history
-/// preservation plus the absence of invented progress.
-///
-public sealed class WingetCliOutputProgressRegressionTests
-{
- private sealed class ProgressProbeOperation : AbstractOperation
- {
- public ProgressProbeOperation()
- : base(queue_enabled: false)
- {
- Metadata.Status = "probe status";
- Metadata.Title = "probe title";
- Metadata.OperationInformation = "probe info";
- Metadata.SuccessTitle = "probe success";
- Metadata.SuccessMessage = "probe success";
- Metadata.FailureTitle = "probe failure";
- Metadata.FailureMessage = "probe failure";
- }
-
- public void EmitForTests(string line, LineType type) => Line(line, type);
-
- protected override void ApplyRetryAction(string retryMode) { }
-
- protected override Task PerformOperation() =>
- Task.FromResult(OperationVeredict.Success);
-
- public override Task GetOperationIcon() =>
- Task.FromResult(new Uri("avares://UniGetUI/Assets/package_color.png"));
- }
-
- ///
- /// Sanitized raw capture: only the user-specific download target path was replaced.
- ///
- private static readonly string[] RealCapturedWingetDownloadLines =
- [
- "Found 7-Zip [7zip.7zip] Version 26.03",
- "This application is licensed to you by its owner.",
- "Microsoft is not responsible for, nor does it grant any licenses to, third-party packages.",
- "Downloading https://www.7-zip.org/a/7z2603-x64.msi",
- "Successfully verified installer hash",
- "Installer downloaded: \\7-Zip_26.03_Machine_X64_wix_en-US.msi",
- ];
-
- private static void ReplayAsProcessReaderWouldSplit(
- ProgressProbeOperation op,
- string rawLine
- )
- {
- // AbstractProcessOperation: text terminated by CR is emitted as a progress
- // indicator; the bare LF that follows promotes it to a regular line.
- op.EmitForTests(rawLine, LineType.ProgressIndicator);
- op.EmitForTests(rawLine, LineType.Information);
- }
-
- [Fact]
- public void RealWingetDownloadOutput_PreservedInHistory_CreatesNoStructuredProgress()
- {
- using var op = new ProgressProbeOperation();
- int progressEvents = 0;
- op.ProgressChanged += (_, _) => progressEvents++;
-
- foreach (string line in RealCapturedWingetDownloadLines)
- ReplayAsProcessReaderWouldSplit(op, line);
-
- // Detailed CLI output is preserved for troubleshooting/history: progress
- // indicator frames stay out of the stored output, regular lines stay in.
- var stored = op.GetOutput();
- Assert.Equal(RealCapturedWingetDownloadLines.Length, stored.Count);
- Assert.Equal(
- RealCapturedWingetDownloadLines,
- stored.Select(entry => entry.Item1).ToArray()
- );
- Assert.All(stored, static entry => Assert.Equal(LineType.Information, entry.Item2));
-
- // And no determinate progress is invented from lines that carry no numbers.
- Assert.Equal(0, progressEvents);
- Assert.Equal(OperationProgress.Unknown, op.CurrentProgress);
- }
-
- [Fact]
- public void RealWingetDownloadOutput_ContainsNoParsableProgressSignals()
- {
- // Pins the §5 evidence: if a future winget version adds byte counters or
- // percentages to piped output, this documents the exact previously-observed
- // shape that justified staying indeterminate.
- foreach (string line in RealCapturedWingetDownloadLines)
- {
- Assert.DoesNotContain("%", line, StringComparison.Ordinal);
- Assert.DoesNotContain("MB", line, StringComparison.OrdinalIgnoreCase);
- Assert.DoesNotContain("KB", line, StringComparison.OrdinalIgnoreCase);
- Assert.DoesNotContain("GB", line, StringComparison.OrdinalIgnoreCase);
- Assert.DoesNotContain("B/s", line, StringComparison.OrdinalIgnoreCase);
- }
- }
-}