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)); + } +}