diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json
index 3c7ebd6a9e..459f2fd2ae 100644
--- a/src/Languages/lang_en.json
+++ b/src/Languages/lang_en.json
@@ -1685,5 +1685,13 @@
"{0} allows stopping running applications.": "{0} allows stopping running applications.",
"{0} allows uninstalling the previous version.": "{0} allows uninstalling the previous version.",
"{0} warning(s)": "{0} warning(s)",
- "Invalid policy files cannot be changed from UniGetUI. An administrator must correct or replace the protected policy file outside this app.": "Invalid policy files cannot be changed from UniGetUI. An administrator must correct or replace the protected policy file outside this app."
+ "Invalid policy files cannot be changed from UniGetUI. An administrator must correct or replace the protected policy file outside this app.": "Invalid policy files cannot be changed from UniGetUI. An administrator must correct or replace the protected policy file outside this app.",
+ "Downloading": "Downloading",
+ "Installing": "Installing",
+ "Updating": "Updating",
+ "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 d883a7f52d..5b5ff7f5a7 100644
--- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
+++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
@@ -57,6 +57,13 @@ public sealed partial class OperationViewModel : ViewModelBase
private static readonly Uri _fallbackIconUri =
new("avares://UniGetUI/Assets/package_color.png");
+ // 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)
{
Operation = operation;
@@ -75,7 +82,28 @@ 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 (_controller.TryApplyLogLine(ev.Item1, ev.Item2, out string liveLine))
+ {
+ LiveLine = liveLine;
+ }
+ });
+
+ operation.ProgressChanged += (_, progress) =>
+ Dispatcher.UIThread.Post(() =>
+ {
+ var (isIndeterminate, value, liveLine) = _controller.ApplyProgress(
+ Operation.Status,
+ progress
+ );
+ ProgressIndeterminate = isIndeterminate;
+ ProgressValue = value;
+ LiveLine = liveLine;
+ });
operation.StatusChanged += (_, status) =>
Dispatcher.UIThread.Post(() => ApplyStatus(status));
@@ -107,8 +135,17 @@ public OperationViewModel(AbstractOperation operation)
));
});
- // Sync with current status in case the operation already started
- ApplyStatus(operation.Status);
+ // 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 ──────────────────────────────────────────────────────────
@@ -150,43 +187,44 @@ private async Task LoadIconAsync()
// ── Status → visual properties ────────────────────────────────────────────
private void ApplyStatus(OperationStatus status)
+ {
+ // Determinate ownership ends with the running phase; afterwards log lines
+ // (e.g. the success/failure message) own the status line again.
+ _controller.ApplyStatus(status);
+ ProgressIndeterminate = _controller.Card.IsIndeterminate;
+ ProgressValue = _controller.Card.Value;
+ ApplyStatusVisuals(status);
+ }
+
+ private void ApplyStatusVisuals(OperationStatus status)
{
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.Enums/OperationProgress.cs b/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
new file mode 100644
index 0000000000..b462be21dc
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
@@ -0,0 +1,86 @@
+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
+ );
+
+ ///
+ /// 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.cs b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
index 4b26c17554..bb42bd06cf 100644
--- a/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
+++ b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
@@ -767,6 +767,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
new file mode 100644
index 0000000000..bf2680a306
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Operations/AbstractOperation_Progress.cs
@@ -0,0 +1,410 @@
+using System.Diagnostics;
+using UniGetUI.Core.Logging;
+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 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, 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 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.
+ ///
+ 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 TimestampProvider = static () => Stopwatch.GetTimestamp();
+
+ // Throughput tracker state. All fields are guarded by ProgressLock.
+ private bool HasThroughputBaseline;
+ private ulong LastThroughputBytes;
+ private long LastThroughputTimestamp;
+ private long LastFreshTimestamp;
+ private double? SmoothedBytesPerSecond;
+
+ private const double ThroughputSmoothingAlpha = 0.3;
+
+ ///
+ /// Age after which a previously measured speed is considered stale and omitted
+ /// from display. Fresh byte movement restores it.
+ ///
+ 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)
+ {
+ TimestampProvider = provider;
+ ResetThroughputStateUnlocked();
+ DisarmStaleTimerUnlocked();
+ }
+ }
+
+ ///
+ /// Reports structured progress. Enriches download reports with measured throughput,
+ /// stores the snapshot as , and raises
+ /// . 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)
+ {
+ OperationProgress enriched;
+ lock (ProgressLock)
+ {
+ enriched = EnrichWithThroughputUnlocked(progress);
+ CurrentProgress = enriched;
+ }
+ NotifyProgressSubscribers(enriched);
+ }
+
+ 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)
+ {
+ 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) 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();
+ DisarmStaleTimerUnlocked();
+ 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;
+ LastThroughputTimestamp = now;
+ LastFreshTimestamp = now;
+ SmoothedBytesPerSecond = null;
+ DisarmStaleTimerUnlocked();
+ 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;
+ LastThroughputTimestamp = now;
+ LastFreshTimestamp = now;
+ SmoothedBytesPerSecond = null;
+ DisarmStaleTimerUnlocked();
+ return progress with { BytesPerSecond = null };
+ }
+
+ if (bytes == LastThroughputBytes)
+ {
+ // 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 = Stopwatch.GetElapsedTime(LastThroughputTimestamp, now);
+ 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;
+ 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;
+ 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 5d7734475b..48d66b959e 100644
--- a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs
+++ b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs
@@ -47,12 +47,20 @@ 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;
try
{
CancellationToken.ThrowIfCancellationRequested();
+ ReportProgress(OperationProgress.ForStage(OperationProgressStage.Downloading));
Line(
$"Fetching download url for package {_package.Name} from {_package.Manager.DisplayName}...",
LineType.Information
@@ -86,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,
@@ -124,6 +132,12 @@ protected override async Task PerformOperation()
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
new file mode 100644
index 0000000000..c2a83277ce
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Operations/OperationCardProgressState.cs
@@ -0,0 +1,72 @@
+using UniGetUI.PackageEngine.Enums;
+
+namespace UniGetUI.PackageOperations;
+
+///
+/// Pure, UI-framework-agnostic mapping from operation status plus generic
+/// to operation-card progress visuals.
+/// 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.
+///
+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..4318f87012
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs
@@ -0,0 +1,93 @@
+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 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
+{
+ 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 downloaded = CoreTools.FormatAsSize((long)progress.BytesDownloaded.Value);
+ string total = CoreTools.FormatAsSize((long)progress.BytesTotal.Value);
+ string? throughput = FormatThroughput(progress.BytesPerSecond);
+ 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 CoreTools.Translate("{0} · {1}%", 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
+ or OperationProgressStage.Installing
+ or OperationProgressStage.Updating
+ or OperationProgressStage.Uninstalling => CoreTools.Translate(
+ "{0}...",
+ StageLabel(stage)
+ ),
+ _ => CoreTools.Translate("Please wait..."),
+ };
+
+ ///
+ /// 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 31397b9ab4..f5f900d14d 100644
--- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
+++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
@@ -303,6 +303,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();
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/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
new file mode 100644
index 0000000000..ee54aa151d
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs
@@ -0,0 +1,65 @@
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageOperations;
+
+namespace UniGetUI.PackageEngine.Tests;
+
+///
+/// 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 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_Unknown_StaysIndeterminate()
+ {
+ var card = RunningCard().WithProgress(OperationStatus.Running, null);
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Equal("Please wait...", card.LiveLine);
+ }
+
+ [Fact]
+ public void Running_DeterminateDownload_IsDeterminate()
+ {
+ var card = RunningCard().WithProgress(
+ OperationStatus.Running,
+ OperationProgress.FromDownload(50, 100)
+ );
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(50, card.Value);
+ Assert.Contains("50%", card.LiveLine);
+ }
+
+ [Fact]
+ public void Running_StageOnly_IsIndeterminate()
+ {
+ var card = RunningCard().WithProgress(
+ OperationStatus.Running,
+ OperationProgress.ForStage(OperationProgressStage.Installing)
+ );
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Contains("Installing", card.LiveLine);
+ }
+
+ [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);
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs b/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
new file mode 100644
index 0000000000..059a47e51f
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
@@ -0,0 +1,434 @@
+using System.Diagnostics;
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageOperations;
+using LineType = UniGetUI.PackageOperations.AbstractOperation.LineType;
+
+namespace UniGetUI.PackageEngine.Tests;
+
+///
+/// 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 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 ReportForTests(OperationProgress progress) => ReportProgress(progress);
+
+ public void EmitForTests(string line, LineType type) => Line(line, type);
+
+ public void SetClockForTests(Func provider) =>
+ SetTimestampProviderForTests(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 monotonic clock. Production uses Stopwatch.GetTimestamp();
+ /// tests advance explicitly by Stopwatch frequency ticks.
+ ///
+ private sealed class ManualTimestampClock
+ {
+ private long _ticks;
+
+ public long Now() => _ticks;
+
+ 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, ManualTimestampClock Clock) CreateClockedProbe()
+ {
+ var op = new ProgressProbeOperation();
+ var clock = new ManualTimestampClock();
+ 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_ZeroTotal_IsIndeterminate_NotFakeZero()
+ {
+ var progress = OperationProgress.FromDownload(1234, 0);
+
+ Assert.False(progress.IsDeterminate);
+ Assert.Null(progress.Percentage);
+ }
+
+ [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);
+ }
+
+ // ── Throughput: monotonic 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);
+ }
+ }
+
+ [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);
+ }
+ }
+
+ [Fact]
+ public void ZeroTimeDelta_PreservesPreviousSpeedWithoutNaN()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ 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);
+
+ ReportDownload(op, 3 * OneMiB);
+ Assert.Equal(speed, op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [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);
+
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 512);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 512 + OneMiB);
+ Assert.Equal((double)OneMiB, 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);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 3 * OneMiB);
+ return op.CurrentProgress.BytesPerSecond;
+ }
+ }
+
+ double? first = RunSequence();
+ double? second = RunSequence();
+
+ Assert.NotNull(first);
+ Assert.Equal(first, second);
+ Assert.InRange(first!.Value, 1.3 * OneMiB - 1, 1.3 * OneMiB + 1);
+ }
+
+ [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);
+
+ op.ReportForTests(OperationProgress.ForStage(OperationProgressStage.Installing));
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 2 * OneMiB);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ // ── Stale speed expiry ─────────────────────────────────────────────────
+
+ [Fact]
+ public void FreshSpeed_IsVisible_AndArmsExpiryTimer()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, OneMiB);
+
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+ Assert.True(op.IsStaleSpeedTimerArmedForTests());
+ }
+ }
+
+ [Fact]
+ 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);
+ Assert.NotNull(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 FreshSample_AfterStale_RestoresSpeed()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, OneMiB);
+
+ clock.Advance(TimeSpan.FromSeconds(3));
+ Assert.True(op.ExpireStaleSpeedForTests());
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 2 * OneMiB);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void RepeatedCounter_WhenStale_DoesNotFabricateSpeed()
+ {
+ 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);
+
+ // Fresh repeated counter preserves the previous speed.
+ clock.Advance(TimeSpan.FromMilliseconds(500));
+ ReportDownload(op, OneMiB);
+ Assert.Equal(speed, op.CurrentProgress.BytesPerSecond);
+
+ // 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 StageReset_StopsExpiryMechanism()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, OneMiB);
+ Assert.True(op.IsStaleSpeedTimerArmedForTests());
+
+ op.ReportForTests(OperationProgress.ForStage(OperationProgressStage.Installing));
+ Assert.False(op.IsStaleSpeedTimerArmedForTests());
+ Assert.False(op.ExpireStaleSpeedForTests());
+ }
+ }
+
+ // ── Subscriber isolation: progress never fails the operation ───────────
+
+ [Fact]
+ public void ThrowingSubscriber_DoesNotEscape_AndLaterSubscriberStillReceives()
+ {
+ using var op = new ProgressProbeOperation();
+ bool secondReceived = false;
+ op.ProgressChanged += (_, _) => throw new InvalidOperationException("display bug");
+ op.ProgressChanged += (_, _) => secondReceived = true;
+
+ var progress = OperationProgress.FromDownload(50, 100);
+ var ex = Record.Exception(() => op.ReportForTests(progress));
+
+ Assert.Null(ex);
+ Assert.True(secondReceived);
+ Assert.Equal(50, op.CurrentProgress.Percentage);
+ }
+
+ [Fact]
+ public void ThrowingSubscriber_DoesNotTurnSuccessfulOperationIntoFailure()
+ {
+ using var op = new ProgressProbeOperation();
+ op.ProgressChanged += (_, _) => throw new InvalidOperationException("display bug");
+
+ var ex = Record.Exception(() =>
+ op.ReportForTests(OperationProgress.FromDownload(10, 100))
+ );
+
+ Assert.Null(ex);
+ Assert.True(op.CurrentProgress.IsDeterminate);
+ }
+
+ // ── Separation: progress never touches log/history ─────────────────────
+
+ [Fact]
+ public void ReportProgress_DoesNotWriteToOperationOutput()
+ {
+ using var op = new ProgressProbeOperation();
+ 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.ReportForTests(OperationProgress.Unknown);
+
+ Assert.Empty(op.GetOutput());
+ }
+
+ [Fact]
+ public void ProgressIndicatorLogLines_DoNotCreateStructuredProgress()
+ {
+ using var op = new ProgressProbeOperation();
+ int progressEvents = 0;
+ op.ProgressChanged += (_, _) => progressEvents++;
+
+ 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());
+ }
+
+ // ── Formatter (small representative set) ───────────────────────────────
+
+ [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("/s", text);
+ }
+
+ [Fact]
+ public void Formatter_StaleSpeed_IsOmitted()
+ {
+ var progress = OperationProgress.FromDownload(50, 100) with { BytesPerSecond = (double?)null };
+
+ Assert.DoesNotContain("/s", OperationProgressFormatter.Format(progress));
+ }
+}