From f6847315a16fa4d422468adc160381aa78cae086 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Mon, 10 Apr 2023 12:44:53 +0100 Subject: [PATCH] feat: add Progress.OnProgressChanged Provides a mechanism to wrap the ProgressChanged event of a Progress as an IObservable. --- CHANGELOG.md | 3 + X10D.Tests/X10D.Tests.csproj | 1 + X10D.Tests/src/Reactive/ProgressTests.cs | 67 ++++++++++++++++ X10D/src/Reactive/ObservableDisposer.cs | 48 ++++++++++++ X10D/src/Reactive/ProgressExtensions.cs | 97 ++++++++++++++++++++++++ X10D/src/Reactive/ProgressObservable.cs | 40 ++++++++++ 6 files changed, 256 insertions(+) create mode 100644 X10D.Tests/src/Reactive/ProgressTests.cs create mode 100644 X10D/src/Reactive/ObservableDisposer.cs create mode 100644 X10D/src/Reactive/ProgressExtensions.cs create mode 100644 X10D/src/Reactive/ProgressObservable.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 343b6dd..643123d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 4.0.0 - [Unreleased] ### Added + - X10D: Added extension methods for `DateOnly`, for parity with `DateTime` and `DateTimeOffset`. - X10D: Added math-related extension methods for `BigInteger`. - X10D: Added `Span.Replace(T, T)`. - X10D: Added `CountDigits` for integer types. +- X10D: Added `Progress.OnProgressChanged([T])`; - X10D: Added `TextWriter.WriteNoAlloc(int[, ReadOnlySpan[, IFormatProvider]])`. - X10D: Added `TextWriter.WriteNoAlloc(uint[, ReadOnlySpan[, IFormatProvider]])`. - X10D: Added `TextWriter.WriteNoAlloc(long[, ReadOnlySpan[, IFormatProvider]])`. @@ -24,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - X10D.Unity: Added `GameObject.CopyComponent(GameObject)` and `GameObject.MoveComponent(GameObject)`. ### Changed + - X10D: `DateTime.Age(DateTime)` and `DateTimeOffset.Age(DateTimeOffset)` parameter renamed from `asOf` to `referenceDate`. ## [3.2.0] - 2023-04-03 diff --git a/X10D.Tests/X10D.Tests.csproj b/X10D.Tests/X10D.Tests.csproj index d763c8c..00ea605 100644 --- a/X10D.Tests/X10D.Tests.csproj +++ b/X10D.Tests/X10D.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/X10D.Tests/src/Reactive/ProgressTests.cs b/X10D.Tests/src/Reactive/ProgressTests.cs new file mode 100644 index 0000000..fce2683 --- /dev/null +++ b/X10D.Tests/src/Reactive/ProgressTests.cs @@ -0,0 +1,67 @@ +using NUnit.Framework; +using X10D.Reactive; + +namespace X10D.Tests.Reactive; + +[TestFixture] +public class ProgressTests +{ + [Test] + public void OnProgressChanged_ShouldCallCompletionDelegate_GivenCompletionValue() + { + var subscriberWasCalled = false; + var completionWasCalled = false; + + var progress = new Progress(); + progress.OnProgressChanged(1.0f).Subscribe(_ => subscriberWasCalled = true, () => completionWasCalled = true); + + ((IProgress)progress).Report(0.5f); + ((IProgress)progress).Report(1.0f); + + Thread.Sleep(1000); + Assert.That(subscriberWasCalled); + Assert.That(completionWasCalled); + } + + [Test] + public void OnProgressChanged_ShouldCallSubscribers_OnProgressChanged() + { + var subscriberWasCalled = false; + + var progress = new Progress(); + progress.OnProgressChanged().Subscribe(_ => subscriberWasCalled = true); + + ((IProgress)progress).Report(0.5f); + + Thread.Sleep(1000); + Assert.That(subscriberWasCalled); + } + + [Test] + public void OnProgressChanged_ShouldCallSubscribers_OnProgressChanged_GivenCompletionValue() + { + var subscriberWasCalled = false; + + var progress = new Progress(); + progress.OnProgressChanged(1.0f).Subscribe(_ => subscriberWasCalled = true); + + ((IProgress)progress).Report(0.5f); + + Thread.Sleep(1000); + Assert.That(subscriberWasCalled); + } + + [Test] + public void OnProgressChanged_ShouldThrowArgumentNullException_GivenNullProgress() + { + Progress progress = null!; + Assert.Throws(() => progress.OnProgressChanged()); + } + + [Test] + public void OnProgressChanged_ShouldThrowArgumentNullException_GivenNullProgressAndCompletionValue() + { + Progress progress = null!; + Assert.Throws(() => progress.OnProgressChanged(1.0f)); + } +} diff --git a/X10D/src/Reactive/ObservableDisposer.cs b/X10D/src/Reactive/ObservableDisposer.cs new file mode 100644 index 0000000..9073ff9 --- /dev/null +++ b/X10D/src/Reactive/ObservableDisposer.cs @@ -0,0 +1,48 @@ +namespace X10D.Reactive; + +/// +/// Represents a disposable that removes an observer from a collection of observers. +/// +internal readonly struct ObservableDisposer : IDisposable +{ + private readonly HashSet> _observers; + private readonly IObserver _observer; + private readonly Action? _additionalAction; + + /// + /// Initializes a new instance of the struct. + /// + /// A collection of observers from which to remove the specified observer. + /// The observer to remove from the collection. + /// The additional action to run on dispose. + public ObservableDisposer(HashSet> observers, IObserver observer, Action? additionalAction) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(observers); + ArgumentNullException.ThrowIfNull(observer); +#else + if (observers is null) + { + throw new ArgumentNullException(nameof(observers)); + } + + if (observer is null) + { + throw new ArgumentNullException(nameof(observer)); + } +#endif + + _observers = observers; + _observer = observer; + _additionalAction = additionalAction; + } + + /// + /// Removes the observer from the collection of observers. + /// + public void Dispose() + { + _observers.Remove(_observer); + _additionalAction?.Invoke(); + } +} diff --git a/X10D/src/Reactive/ProgressExtensions.cs b/X10D/src/Reactive/ProgressExtensions.cs new file mode 100644 index 0000000..6128a72 --- /dev/null +++ b/X10D/src/Reactive/ProgressExtensions.cs @@ -0,0 +1,97 @@ +namespace X10D.Reactive; + +/// +/// Provides extension methods for . +/// +public static class ProgressExtensions +{ + /// + /// Wraps the event of the current in an + /// object. + /// + /// The progress whose event to wrap. + /// The type of progress update value. + /// + /// An object that wraps the event of the current + /// . + /// + /// is . + public static IObservable OnProgressChanged(this Progress progress) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(progress); +#else + if (progress is null) + { + throw new ArgumentNullException(nameof(progress)); + } +#endif + + var progressObservable = new ProgressObservable(); + + void ProgressChangedHandler(object? sender, T args) + { + IObserver[] observers = progressObservable.Observers; + + for (var index = 0; index < observers.Length; index++) + { + observers[index].OnNext(args); + } + } + + progress.ProgressChanged += ProgressChangedHandler; + progressObservable.OnDispose = () => progress.ProgressChanged -= ProgressChangedHandler; + + return progressObservable; + } + + /// + /// Wraps the event of the current in an + /// object, and completes the observable when the progress reaches the specified value. + /// + /// The progress whose event to wrap. + /// The value that indicates completion. + /// The type of progress update value. + /// + /// An object that wraps the event of the current + /// . + /// + /// is . + public static IObservable OnProgressChanged(this Progress progress, T completeValue) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(progress); +#else + if (progress is null) + { + throw new ArgumentNullException(nameof(progress)); + } +#endif + + var progressObservable = new ProgressObservable(); + var comparer = EqualityComparer.Default; + + void ProgressChangedHandler(object? sender, T args) + { + IObserver[] observers = progressObservable.Observers; + + for (var index = 0; index < observers.Length; index++) + { + observers[index].OnNext(args); + } + + if (comparer.Equals(args, completeValue)) + { + for (var index = 0; index < observers.Length; index++) + { + observers[index].OnCompleted(); + } + } + } + + progress.ProgressChanged += ProgressChangedHandler; + progressObservable.OnDispose = () => progress.ProgressChanged -= ProgressChangedHandler; + + return progressObservable; + } +} diff --git a/X10D/src/Reactive/ProgressObservable.cs b/X10D/src/Reactive/ProgressObservable.cs new file mode 100644 index 0000000..29a8243 --- /dev/null +++ b/X10D/src/Reactive/ProgressObservable.cs @@ -0,0 +1,40 @@ +namespace X10D.Reactive; + +/// +/// Represents a concrete implementation of that tracks progress of a . +/// +internal sealed class ProgressObservable : IObservable +{ + private readonly HashSet> _observers = new(); + + /// + /// Gets the observers. + /// + /// The observers. + public IObserver[] Observers + { + get => _observers.ToArray(); + } + + internal Action? OnDispose { get; set; } + + /// + /// Subscribes the specified observer to the progress tracker. + /// + /// The observer. + /// An object which can be disposed to unsubscribe from progress tracking. + public IDisposable Subscribe(IObserver observer) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(observer); +#else + if (observer is null) + { + throw new ArgumentNullException(nameof(observer)); + } +#endif + + _observers.Add(observer); + return new ObservableDisposer(_observers, observer, OnDispose); + } +}