diff --git a/analyzers/disabled.editorconfig b/analyzers/disabled.editorconfig
index 9f7d880..85786d5 100644
--- a/analyzers/disabled.editorconfig
+++ b/analyzers/disabled.editorconfig
@@ -1,5 +1,10 @@
is_global = true
+# CA1033: Interface methods should be callable by child types
+# Some childs should not have the parent methods
+# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1033
+dotnet_diagnostic.CA1033.severity = none
+
# CA1031: Do not catch general exception types
# Sometimes we don't know the exception.
# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1031
diff --git a/analyzers/sonar.editorconfig b/analyzers/sonar.editorconfig
index c659aaf..bebc659 100644
--- a/analyzers/sonar.editorconfig
+++ b/analyzers/sonar.editorconfig
@@ -7,3 +7,7 @@ dotnet_diagnostic.S101.severity = none
# S4200: Make this wrapper for native method less trivial
# Some method are trivial and should be kept as is
dotnet_diagnostic.S4200.severity = none
+
+# S6966: Use async method instead.
+# Is a duplicate of CA1849: https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1849
+dotnet_diagnostic.S6966.severity = none
diff --git a/analyzers/vsthrd.editorconfig b/analyzers/vsthrd.editorconfig
new file mode 100644
index 0000000..c45f7cc
--- /dev/null
+++ b/analyzers/vsthrd.editorconfig
@@ -0,0 +1,6 @@
+is_global = true
+
+# VSTHRD103: Call async methods when in an async method
+# Is a duplicate of CA1849: https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1849
+# https://microsoft.github.io/vs-threading/analyzers/VSTHRD103.html
+dotnet_diagnostic.VSTHRD103.severity = none
diff --git a/quack.slnx b/quack.slnx
index 8483613..4ad6bd0 100644
--- a/quack.slnx
+++ b/quack.slnx
@@ -11,6 +11,7 @@
+
diff --git a/src/KappaDuck.Quack/Progress/AsyncIndeterminateProgressReporter.cs b/src/KappaDuck.Quack/Progress/AsyncIndeterminateProgressReporter.cs
new file mode 100644
index 0000000..548338e
--- /dev/null
+++ b/src/KappaDuck.Quack/Progress/AsyncIndeterminateProgressReporter.cs
@@ -0,0 +1,67 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Progress;
+
+///
+/// Asynchronous indeterminate progress reporter handed to
+/// .
+///
+public sealed class AsyncIndeterminateProgressReporter : IDisposable
+{
+ private readonly CancellationTokenSource _cts = new();
+
+ internal AsyncIndeterminateProgressReporter()
+ {
+ }
+
+ ///
+ /// Gets the token to observe for cancellation during the reporting.
+ ///
+ public CancellationToken CancellationToken => _cts.Token;
+
+ ///
+ /// Requests cancellation of the progress operation.
+ ///
+ ///
+ /// Stops any further reporting, triggers and resets the bar.
+ ///
+ public void Cancel()
+ {
+ if (CancellationToken.IsCancellationRequested)
+ return;
+
+ _cts.Cancel();
+ }
+
+ ///
+ /// Requests cancellation of the progress operation after a delay.
+ ///
+ /// The delay after which to cancel.
+ public void CancelAfter(TimeSpan delay)
+ {
+ if (CancellationToken.IsCancellationRequested)
+ return;
+
+ _cts.CancelAfter(delay);
+ }
+
+ ///
+ public void CancelAfter(int millisecondsDelay) => CancelAfter(TimeSpan.FromMilliseconds(millisecondsDelay));
+
+ ///
+ /// Asynchronously requests cancellation of the progress operation.
+ ///
+ /// A task that represents the asynchronous cancellation.
+ public Task CancelAsync()
+ {
+ if (CancellationToken.IsCancellationRequested)
+ return Task.CompletedTask;
+
+ return _cts.CancelAsync();
+ }
+
+ ///
+ public void Dispose() => _cts.Dispose();
+}
+
diff --git a/src/KappaDuck.Quack/Progress/AsyncProgressReporter.cs b/src/KappaDuck.Quack/Progress/AsyncProgressReporter.cs
new file mode 100644
index 0000000..699797b
--- /dev/null
+++ b/src/KappaDuck.Quack/Progress/AsyncProgressReporter.cs
@@ -0,0 +1,117 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Progress;
+
+///
+/// Asynchronous determinate progress reporter handed to
+/// .
+///
+public sealed class AsyncProgressReporter : IDisposable
+{
+ private readonly IProgressOperation _operation;
+ private readonly CancellationTokenSource _cts = new();
+ private readonly int _total;
+
+ private int _current;
+
+ internal AsyncProgressReporter(IProgressOperation operation, int total)
+ {
+ _operation = operation;
+ _total = total;
+ }
+
+ ///
+ /// Gets the token to observe for cancellation during the reporting.
+ ///
+ public CancellationToken CancellationToken => _cts.Token;
+
+ ///
+ /// Reports progress by incrementing by 1.
+ ///
+ ///
+ /// This is a shorthand for with steps of 1.
+ ///
+ /// Cancellation has been requested.
+ public void Advance() => Increment(1);
+
+ ///
+ /// Requests cancellation of the progress operation.
+ ///
+ ///
+ /// The next call to or throws
+ /// , which the base catches to trigger
+ /// and reset the bar.
+ ///
+ public void Cancel()
+ {
+ if (CancellationToken.IsCancellationRequested)
+ return;
+
+ _cts.Cancel();
+ }
+
+ ///
+ /// Requests cancellation of the progress operation after a delay.
+ ///
+ /// The delay after which to cancel.
+ public void CancelAfter(TimeSpan delay)
+ {
+ if (CancellationToken.IsCancellationRequested)
+ return;
+
+ _cts.CancelAfter(delay);
+ }
+
+ ///
+ public void CancelAfter(int millisecondsDelay) => CancelAfter(TimeSpan.FromMilliseconds(millisecondsDelay));
+
+ ///
+ /// Asynchronously requests cancellation of the progress operation.
+ ///
+ /// A task that represents the asynchronous cancellation.
+ public Task CancelAsync()
+ {
+ if (CancellationToken.IsCancellationRequested)
+ return Task.CompletedTask;
+
+ return _cts.CancelAsync();
+ }
+
+ ///
+ public void Dispose() => _cts.Dispose();
+
+ ///
+ /// Reports progress by incrementing by a step.
+ ///
+ /// The number of steps to increment.
+ /// is negative.
+ /// Cancellation has been requested.
+ public void Increment(int steps)
+ {
+ CancellationToken.ThrowIfCancellationRequested();
+
+ ArgumentOutOfRangeException.ThrowIfNegative(steps);
+ Report(_current + steps);
+ }
+
+ ///
+ /// Reports the absolute current progress.
+ ///
+ ///
+ /// The total provided to is
+ /// used as the maximum limit if is greater than the total.
+ ///
+ /// The current progress.
+ /// is negative.
+ /// Cancellation has been requested.
+ public void Report(int current)
+ {
+ CancellationToken.ThrowIfCancellationRequested();
+
+ ArgumentOutOfRangeException.ThrowIfNegative(current);
+
+ _current = Math.Min(current, _total);
+ _operation.Report((float)_current / _total);
+ }
+}
diff --git a/src/KappaDuck.Quack/Progress/IProgressOperation.cs b/src/KappaDuck.Quack/Progress/IProgressOperation.cs
new file mode 100644
index 0000000..ea13c29
--- /dev/null
+++ b/src/KappaDuck.Quack/Progress/IProgressOperation.cs
@@ -0,0 +1,22 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Progress;
+
+///
+/// Represents a progress operation that can be reported or cancelled.
+///
+public interface IProgressOperation
+{
+ ///
+ /// Reports a normalized progress value between 0 and 1.
+ ///
+ /// The normalized value. Values outside the range are clamped by the sink.
+ void Report(float value);
+
+ ///
+ /// Requests cancellation of the current progress operation.
+ ///
+ void Cancel();
+
+}
diff --git a/src/KappaDuck.Quack/Progress/IndeterminateProgressReporter.cs b/src/KappaDuck.Quack/Progress/IndeterminateProgressReporter.cs
new file mode 100644
index 0000000..5e3c768
--- /dev/null
+++ b/src/KappaDuck.Quack/Progress/IndeterminateProgressReporter.cs
@@ -0,0 +1,27 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Progress;
+
+///
+/// Synchronous indeterminate progress reporter handed to
+/// .
+///
+public sealed class IndeterminateProgressReporter
+{
+ internal IndeterminateProgressReporter()
+ {
+ }
+
+ ///
+ /// Requests cancellation of the progress operation.
+ ///
+ ///
+ /// Throws , which the base catches to trigger
+ /// and reset the bar.
+ ///
+ /// Always thrown.
+ [DoesNotReturn]
+ [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "The method is part of the instance API.")]
+ public void Cancel() => throw new OperationCanceledException("The indeterminate progress operation has been cancelled.");
+}
diff --git a/src/KappaDuck.Quack/Progress/ProgressBar.cs b/src/KappaDuck.Quack/Progress/ProgressBar.cs
new file mode 100644
index 0000000..a40ff98
--- /dev/null
+++ b/src/KappaDuck.Quack/Progress/ProgressBar.cs
@@ -0,0 +1,281 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+using KappaDuck.Quack.Exceptions;
+using KappaDuck.Quack.Geometry;
+
+namespace KappaDuck.Quack.Progress;
+#pragma warning disable SYSLIB5007, CA2252
+
+///
+/// Base class for a progress bar backend, e.g. a window's taskbar icon, an on-screen control or a console bar.
+///
+///
+///
+/// Owns the reporting lifecycle, value normalization, completion detection and events. A concrete backend
+/// only translates a and a normalized value into its own representation by
+/// implementing and .
+///
+///
+/// The base is intentionally thread-agnostic: reporting and event raising run on whichever thread calls into
+/// it. Any thread affinity (e.g. marshalling onto a UI/main thread) is a backend concern and belongs in the
+/// overrides. See and friends for the event hooks.
+///
+///
+public abstract class ProgressBar : IProgressOperation
+{
+ private float _lastValue = -1f;
+ private bool _isReporting;
+ private bool _isCompleted;
+
+ ///
+ /// Occurs when the reporting is cancelled.
+ ///
+ public event EventHandler? Cancelled;
+
+ ///
+ /// Occurs when the reporting is completed.
+ ///
+ public event EventHandler? Completed;
+
+ ///
+ /// Occurs when an unhandled exception is encountered during the reporting.
+ ///
+ public event EventHandler? ErrorOccurred;
+
+ ///
+ /// Occurs when the normalized progress value changes.
+ ///
+ public event EventHandler? ProgressChanged;
+
+ ///
+ /// Raises . Override to control the thread it runs on.
+ ///
+ /// The normalized value.
+ protected virtual void OnProgressChanged(float value) => ProgressChanged?.Invoke(this, new ProgressValueEventArgs(value));
+
+ ///
+ /// Raises . Override to control the thread it runs on.
+ ///
+ protected virtual void OnCompleted() => Completed?.Invoke(this, EventArgs.Empty);
+
+ ///
+ /// Raises . Override to control the thread it runs on.
+ ///
+ protected virtual void OnCancelled() => Cancelled?.Invoke(this, EventArgs.Empty);
+
+ ///
+ /// Raises . Override to control the thread it runs on.
+ ///
+ /// The exception that was caught.
+ protected virtual void OnErrorOccurred(Exception exception) => ErrorOccurred?.Invoke(this, new ProgressErrorEventArgs(exception));
+
+ ///
+ /// Applies a to the backend.
+ ///
+ /// The state to apply.
+ protected abstract void SetState(ProgressState state);
+
+ ///
+ /// Applies a normalized between 0 and 1 to the backend.
+ ///
+ ///
+ /// Already clamped by the base; the backend receives only meaningful changes.
+ ///
+ /// The normalized value between 0 and 1.
+ protected abstract void SetValue(float value);
+
+ ///
+ /// Starts a synchronous determinate progress operation.
+ ///
+ ///
+ /// Any unhandled exception thrown within is caught, reported to
+ /// and switches the backend to .
+ ///
+ /// The action that performs the progress operation.
+ /// The value representing 100% of the progress.
+ /// value is negative or zero.
+ /// A progress report is already in progress.
+ public void Start(Action action, int total = 100)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(total);
+ ThrowHelper.ThrowIf(_isReporting, "Cannot begin a new progress report while another is in progress.");
+
+ BeginReport(ProgressState.Normal);
+
+ try
+ {
+ ProgressReporter reporter = new(this, total);
+ action(reporter);
+ }
+ catch (Exception exception)
+ {
+ Fail(exception);
+ }
+ }
+
+ ///
+ /// Starts an asynchronous determinate progress operation.
+ ///
+ ///
+ /// The action that performs the progress operation.
+ /// The value representing 100% of the progress.
+ /// The task representing the asynchronous operation.
+ ///
+ public async ValueTask StartAsync(Func action, int total = 100)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(total);
+ ThrowHelper.ThrowIf(_isReporting, "Cannot begin a new progress report while another is in progress.");
+
+ BeginReport(ProgressState.Normal);
+
+ try
+ {
+ using AsyncProgressReporter reporter = new(this, total);
+ await action(reporter).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ Cancel();
+ }
+ catch (Exception exception)
+ {
+ Fail(exception);
+ }
+ }
+
+ ///
+ /// Starts a synchronous indeterminate progress operation.
+ ///
+ ///
+ /// The action that performs the progress operation.
+ /// A progress report is already in progress.
+ public void StartIndeterminate(Action action)
+ {
+ ThrowHelper.ThrowIf(_isReporting, "Cannot begin a new progress report while another is in progress.");
+
+ BeginReport(ProgressState.Indeterminate);
+
+ try
+ {
+ IndeterminateProgressReporter progress = new();
+ action(progress);
+
+ Complete();
+ }
+ catch (OperationCanceledException)
+ {
+ Cancel();
+ }
+ catch (Exception exception)
+ {
+ SetValue(0.5f);
+ Fail(exception);
+ }
+ }
+
+ ///
+ /// Starts an asynchronous indeterminate progress operation.
+ ///
+ ///
+ /// The action that performs the progress operation.
+ /// The task representing the asynchronous operation.
+ /// A progress report is already in progress.
+ public async ValueTask StartIndeterminateAsync(Func action)
+ {
+ ThrowHelper.ThrowIf(_isReporting, "Cannot begin a new progress report while another is in progress.");
+
+ BeginReport(ProgressState.Indeterminate);
+
+ try
+ {
+ using AsyncIndeterminateProgressReporter progress = new();
+ await action(progress).ConfigureAwait(false);
+
+ Complete();
+ }
+ catch (OperationCanceledException)
+ {
+ Cancel();
+ }
+ catch (Exception exception)
+ {
+ SetValue(0.5f);
+ Fail(exception);
+ }
+ }
+
+ ///
+ /// Resets the progress bar to its default, empty state.
+ ///
+ ///
+ /// Useful to clear any existing state or value, especially after encountering an error.
+ ///
+ public void Reset()
+ {
+ _isReporting = false;
+ _isCompleted = false;
+ _lastValue = -1f;
+
+ SetValue(0f);
+ SetState(ProgressState.None);
+ }
+
+ void IProgressOperation.Report(float value)
+ {
+ if (_isCompleted)
+ return;
+
+ float progress = Math.Clamp(value, 0f, 1f);
+
+ if (MathF.ApproximatelyZero(progress - _lastValue))
+ return;
+
+ _lastValue = progress;
+
+ SetValue(progress);
+ OnProgressChanged(progress);
+
+ if (progress >= 1f)
+ Complete();
+ }
+
+ void IProgressOperation.Cancel() => Cancel();
+
+ private void BeginReport(ProgressState state)
+ {
+ _isCompleted = false;
+ _isReporting = true;
+ _lastValue = -1f;
+
+ SetState(state);
+ }
+
+ private void Cancel()
+ {
+ OnCancelled();
+ Reset();
+ }
+
+ private void Complete()
+ {
+ if (_isCompleted)
+ return;
+
+ _isCompleted = true;
+ _isReporting = false;
+
+ OnCompleted();
+ Reset();
+ }
+
+ private void Fail(Exception exception)
+ {
+ _isCompleted = false;
+ _isReporting = false;
+
+ OnErrorOccurred(exception);
+ SetState(ProgressState.Error);
+ }
+}
+#pragma warning restore SYSLIB5007, CA2252
diff --git a/src/KappaDuck.Quack/Progress/ProgressErrorEventArgs.cs b/src/KappaDuck.Quack/Progress/ProgressErrorEventArgs.cs
new file mode 100644
index 0000000..e2bf179
--- /dev/null
+++ b/src/KappaDuck.Quack/Progress/ProgressErrorEventArgs.cs
@@ -0,0 +1,16 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Progress;
+
+///
+/// Provides data for the event.
+///
+/// The exception that was caught during reporting.
+public sealed class ProgressErrorEventArgs(Exception exception) : EventArgs
+{
+ ///
+ /// Gets the exception that was caught during reporting.
+ ///
+ public Exception Exception { get; } = exception;
+}
diff --git a/src/KappaDuck.Quack/Progress/ProgressReporter.cs b/src/KappaDuck.Quack/Progress/ProgressReporter.cs
new file mode 100644
index 0000000..300f5a7
--- /dev/null
+++ b/src/KappaDuck.Quack/Progress/ProgressReporter.cs
@@ -0,0 +1,79 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Progress;
+
+///
+/// Synchronous determinate progress reporter handed to .
+///
+public sealed class ProgressReporter
+{
+ private readonly IProgressOperation _operation;
+ private readonly int _total;
+
+ private bool _isCancelled;
+ private int _current;
+
+ internal ProgressReporter(IProgressOperation operation, int total)
+ {
+ _operation = operation;
+ _total = total;
+ }
+
+ ///
+ /// Reports progress by incrementing by 1.
+ ///
+ ///
+ /// This is a shorthand for with steps of 1.
+ ///
+ public void Advance() => Increment(1);
+
+ ///
+ /// Requests cancellation of the progress operation.
+ ///
+ ///
+ /// Stops any further reporting, triggers and resets the bar.
+ ///
+ public void Cancel()
+ {
+ if (_isCancelled)
+ return;
+
+ _isCancelled = true;
+ _operation.Cancel();
+ }
+
+ ///
+ /// Reports progress by incrementing by a step.
+ ///
+ /// The number of steps to increment.
+ /// is negative.
+ public void Increment(int steps)
+ {
+ if (_isCancelled)
+ return;
+
+ ArgumentOutOfRangeException.ThrowIfNegative(steps);
+ Report(_current + steps);
+ }
+
+ ///
+ /// Reports the absolute current progress.
+ ///
+ ///
+ /// The total provided to is used as the maximum
+ /// limit if is greater than the total.
+ ///
+ /// The current progress.
+ /// is negative.
+ public void Report(int current)
+ {
+ if (_isCancelled)
+ return;
+
+ ArgumentOutOfRangeException.ThrowIfNegative(current);
+
+ _current = Math.Min(current, _total);
+ _operation.Report((float)_current / _total);
+ }
+}
diff --git a/src/KappaDuck.Quack/Progress/ProgressState.cs b/src/KappaDuck.Quack/Progress/ProgressState.cs
new file mode 100644
index 0000000..7cdf039
--- /dev/null
+++ b/src/KappaDuck.Quack/Progress/ProgressState.cs
@@ -0,0 +1,30 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Progress;
+
+///
+/// Represents the state of a .
+///
+public enum ProgressState
+{
+ ///
+ /// No progress is shown; the bar is hidden or cleared.
+ ///
+ None = 0,
+
+ ///
+ /// A normal, determinate progress value is shown.
+ ///
+ Normal = 1,
+
+ ///
+ /// An ongoing operation with no measurable progress is shown.
+ ///
+ Indeterminate = 2,
+
+ ///
+ /// An error state is shown.
+ ///
+ Error = 3
+}
diff --git a/src/KappaDuck.Quack/Progress/ProgressValueEventArgs.cs b/src/KappaDuck.Quack/Progress/ProgressValueEventArgs.cs
new file mode 100644
index 0000000..7094bac
--- /dev/null
+++ b/src/KappaDuck.Quack/Progress/ProgressValueEventArgs.cs
@@ -0,0 +1,16 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Progress;
+
+///
+/// Provides data for the event.
+///
+/// The normalized progress value between 0 and 1.
+public sealed class ProgressValueEventArgs(float value) : EventArgs
+{
+ ///
+ /// Gets the normalized progress value between 0 and 1.
+ ///
+ public float Value { get; } = value;
+}
diff --git a/tests/.editorconfig b/tests/.editorconfig
index 2102252..83a4850 100644
--- a/tests/.editorconfig
+++ b/tests/.editorconfig
@@ -2,6 +2,11 @@
# Disabled Microsoft.VisualStudio.Threading.Analyzers rules
+# VSTHRD111: Consider calling ConfigureAwait on the awaited task
+# Reason: don't need to configure the await context
+# https://github.com/Microsoft/vs-threading/blob/main/doc/analyzers/VSTHRD111.md
+dotnet_diagnostic.VSTHRD111.severity = none
+
# VSTHRD200: Use "Async" suffix for async methods
# Reason: Don't need to use Async suffix for test methods and give better clarity
# https://github.com/Microsoft/vs-threading/blob/main/doc/analyzers/VSTHRD200.md
diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props
index daeaa51..226a507 100644
--- a/tests/Directory.Build.props
+++ b/tests/Directory.Build.props
@@ -14,6 +14,7 @@
+
@@ -22,6 +23,8 @@
+
+
diff --git a/tests/Directory.Packages.props b/tests/Directory.Packages.props
index de069a0..f6c2293 100644
--- a/tests/Directory.Packages.props
+++ b/tests/Directory.Packages.props
@@ -1,7 +1,9 @@
-
-
+
+
+
+
diff --git a/tests/Unit.Tests/Progress/AsyncIndeterminateProgressReporterTests.cs b/tests/Unit.Tests/Progress/AsyncIndeterminateProgressReporterTests.cs
new file mode 100644
index 0000000..e5c674d
--- /dev/null
+++ b/tests/Unit.Tests/Progress/AsyncIndeterminateProgressReporterTests.cs
@@ -0,0 +1,29 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+using KappaDuck.Quack.Progress;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Unit.Tests.Progress;
+
+internal sealed class AsyncIndeterminateProgressReporterTests : IDisposable
+{
+ private readonly AsyncIndeterminateProgressReporter _reporter = new();
+
+ public void Dispose() => _reporter.Dispose();
+
+ [Test]
+ [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "The test requires to test the method Cancel")]
+ public async Task CancelShouldRequestToCancelTheToken()
+ {
+ _reporter.Cancel();
+ await _reporter.CancellationToken.IsCancellationRequested.Should().BeTrue();
+ }
+
+ [Test]
+ public async Task CancelAsyncShouldRequestToCancelTheToken()
+ {
+ await _reporter.CancelAsync();
+ await _reporter.CancellationToken.IsCancellationRequested.Should().BeTrue();
+ }
+}
diff --git a/tests/Unit.Tests/Progress/AsyncProgressReporterTests.cs b/tests/Unit.Tests/Progress/AsyncProgressReporterTests.cs
new file mode 100644
index 0000000..eba780c
--- /dev/null
+++ b/tests/Unit.Tests/Progress/AsyncProgressReporterTests.cs
@@ -0,0 +1,132 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+using KappaDuck.Quack.Progress;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Unit.Tests.Progress;
+
+internal sealed class AsyncProgressReporterTests : IDisposable
+{
+ private readonly Mock _operation = IProgressOperation.Mock();
+ private readonly AsyncProgressReporter _reporter;
+
+ public AsyncProgressReporterTests()
+ {
+ _reporter = new AsyncProgressReporter(_operation.Object, 100);
+ }
+
+ public void Dispose() => _reporter.Dispose();
+
+ [Test]
+ [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "The test requires to test the method Cancel")]
+ public async Task CancelShouldRequestToCancelTheToken()
+ {
+ _reporter.Cancel();
+ await _reporter.CancellationToken.IsCancellationRequested.Should().BeTrue();
+ }
+
+ [Test]
+ public async Task CancelAsyncShouldRequestToCancelTheToken()
+ {
+ await _reporter.CancelAsync();
+ await _reporter.CancellationToken.IsCancellationRequested.Should().BeTrue();
+ }
+
+ [Test]
+ public void ReportShouldReportValueToOperation()
+ {
+ _reporter.Report(100);
+ _operation.Report(1).WasCalled(Times.Once);
+ }
+
+ [Test]
+ public async Task ReportShouldThrowExceptionWhenValueIsNegative()
+ {
+ await Assert.That(() => _reporter.Report(-100))
+ .ThrowsExactly();
+
+ _operation.Report(Any()).WasNeverCalled();
+ }
+
+ [Test]
+ public void ReportShouldClampToTheTotalWhenValueIsGreaterThanTotal()
+ {
+ _reporter.Report(999);
+ _operation.Report(1).WasCalled(Times.Once);
+ }
+
+ [Test]
+ public async Task ReportShouldThrowOperationCanceledExceptionWhenIsCancelled()
+ {
+ await _reporter.CancelAsync();
+
+ await Assert.That(() => _reporter.Report(100))
+ .ThrowsExactly();
+
+ _operation.Report(Any()).WasNeverCalled();
+ }
+
+ [Test]
+ public void IncrementShouldIncrementByStep()
+ {
+ _reporter.Increment(25);
+ _operation.Report(0.25f).WasCalled(Times.Once);
+ }
+
+ [Test]
+ public void IncrementShouldCumulateAndIncrementTheStepsWhenCallingMultipleTimes()
+ {
+ _reporter.Increment(25);
+ _reporter.Increment(25);
+
+ _operation.Report(0.5f).WasCalled(Times.Once);
+ }
+
+ [Test]
+ public async Task IncrementShouldThrowExceptionWhenValueIsNegative()
+ {
+ await Assert.That(() => _reporter.Increment(-25))
+ .ThrowsExactly();
+
+ _operation.Report(Any()).WasNeverCalled();
+ }
+
+ [Test]
+ public async Task IncrementShouldThrowOperationCanceledExceptionWhenIsCancelled()
+ {
+ await _reporter.CancelAsync();
+
+ await Assert.That(() => _reporter.Increment(-25))
+ .ThrowsExactly();
+
+ _operation.Report(Any()).WasNeverCalled();
+ }
+
+ [Test]
+ public void AdvanceShouldIncrementByOneStep()
+ {
+ _reporter.Advance();
+ _operation.Report(0.01f).WasCalled(Times.Once);
+ }
+
+ [Test]
+ public void AdvanceShouldCumulateAndAdvanceByOneStepWhenCallingMultipleTimes()
+ {
+ _reporter.Advance();
+ _reporter.Advance();
+
+ _operation.Report(0.02f).WasCalled(Times.Once);
+ }
+
+ [Test]
+ public async Task AdvanceShouldThrowOperationCanceledExceptionWhenIsCancelled()
+ {
+ await _reporter.CancelAsync();
+
+ await Assert.That(_reporter.Advance)
+ .ThrowsExactly();
+
+ _operation.Report(Any()).WasNeverCalled();
+ }
+}
diff --git a/tests/Unit.Tests/Progress/IndeterminateProgressReporterTests.cs b/tests/Unit.Tests/Progress/IndeterminateProgressReporterTests.cs
new file mode 100644
index 0000000..8dc9981
--- /dev/null
+++ b/tests/Unit.Tests/Progress/IndeterminateProgressReporterTests.cs
@@ -0,0 +1,18 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+using KappaDuck.Quack.Progress;
+
+namespace Unit.Tests.Progress;
+
+internal sealed class IndeterminateProgressReporterTests
+{
+ private readonly IndeterminateProgressReporter _reporter = new();
+
+ [Test]
+ public async Task CancelShouldThrowOperationCanceledException()
+ {
+ await Assert.That(_reporter.Cancel)
+ .ThrowsExactly();
+ }
+}
diff --git a/tests/Unit.Tests/Progress/ProgressReporterTests.cs b/tests/Unit.Tests/Progress/ProgressReporterTests.cs
new file mode 100644
index 0000000..9afa79d
--- /dev/null
+++ b/tests/Unit.Tests/Progress/ProgressReporterTests.cs
@@ -0,0 +1,124 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+using KappaDuck.Quack.Progress;
+
+namespace Unit.Tests.Progress;
+
+internal sealed class ProgressReporterTests
+{
+ private readonly Mock _operation = IProgressOperation.Mock();
+ private readonly ProgressReporter _reporter;
+
+ public ProgressReporterTests()
+ {
+ _reporter = new ProgressReporter(_operation.Object, 100);
+ }
+
+ [Test]
+ public void CancelShouldCancelTheOperation()
+ {
+ _reporter.Cancel();
+ _operation.Cancel().WasCalled(Times.Once);
+ }
+
+ [Test]
+ public void CancelShouldNotCancelTheOperationTwiceWhenIsAlreadyCancelled()
+ {
+ _reporter.Cancel();
+ _reporter.Cancel();
+
+ _operation.Cancel().WasCalled(Times.Once);
+ }
+
+ [Test]
+ public void ReportShouldReportValueToOperation()
+ {
+ _reporter.Report(100);
+ _operation.Report(1).WasCalled(Times.Once);
+ }
+
+ [Test]
+ public async Task ReportShouldThrowExceptionWhenValueIsNegative()
+ {
+ await Assert.That(() => _reporter.Report(-100))
+ .ThrowsExactly();
+
+ _operation.Report(Any()).WasNeverCalled();
+ }
+
+ [Test]
+ public void ReportShouldClampToTheTotalWhenValueIsGreaterThanTotal()
+ {
+ _reporter.Report(999);
+ _operation.Report(1).WasCalled(Times.Once);
+ }
+
+ [Test]
+ public void ReportShouldDoNothingWhenIsCancelled()
+ {
+ _reporter.Cancel();
+ _reporter.Report(100);
+
+ _operation.Report(Any()).WasNeverCalled();
+ }
+
+ [Test]
+ public void IncrementShouldIncrementByStep()
+ {
+ _reporter.Increment(25);
+ _operation.Report(0.25f).WasCalled(Times.Once);
+ }
+
+ [Test]
+ public void IncrementShouldCumulateAndIncrementTheStepsWhenCallingMultipleTimes()
+ {
+ _reporter.Increment(25);
+ _reporter.Increment(25);
+
+ _operation.Report(0.5f).WasCalled(Times.Once);
+ }
+
+ [Test]
+ public async Task IncrementShouldThrowExceptionWhenValueIsNegative()
+ {
+ await Assert.That(() => _reporter.Increment(-25))
+ .ThrowsExactly();
+
+ _operation.Report(Any()).WasNeverCalled();
+ }
+
+ [Test]
+ public void IncrementShouldDoNothingWhenIsCancelled()
+ {
+ _reporter.Cancel();
+ _reporter.Increment(25);
+
+ _operation.Report(Any()).WasNeverCalled();
+ }
+
+ [Test]
+ public void AdvanceShouldIncrementByOneStep()
+ {
+ _reporter.Advance();
+ _operation.Report(0.01f).WasCalled(Times.Once);
+ }
+
+ [Test]
+ public void AdvanceShouldCumulateAndAdvanceByOneStepWhenCallingMultipleTimes()
+ {
+ _reporter.Advance();
+ _reporter.Advance();
+
+ _operation.Report(0.02f).WasCalled(Times.Once);
+ }
+
+ [Test]
+ public void AdvanceShouldDoNothingWhenIsCancelled()
+ {
+ _reporter.Cancel();
+ _reporter.Advance();
+
+ _operation.Report(Any()).WasNeverCalled();
+ }
+}