diff --git a/Nuclex.Support (PC).csproj b/Nuclex.Support (PC).csproj index 6823b43..4a20885 100644 --- a/Nuclex.Support (PC).csproj +++ b/Nuclex.Support (PC).csproj @@ -164,6 +164,11 @@ false ThreadedMethodOperation + + false + SetProgression.Test + SetProgression.cs + false WeightedProgression diff --git a/Source/Collections/ObservableCollection.Test.cs b/Source/Collections/ObservableCollection.Test.cs index 7cb1afd..2137d36 100644 --- a/Source/Collections/ObservableCollection.Test.cs +++ b/Source/Collections/ObservableCollection.Test.cs @@ -49,35 +49,36 @@ namespace Nuclex.Support.Collections { new EventHandler.ItemEventArgs>( this.mockedSubscriber.ItemRemoved ); + + this.mockery.VerifyAllExpectationsHaveBeenMet(); } /// Tests whether the Clearing event is fired [Test] public void TestClearingEvent() { - Expect.Once.On(this.mockedSubscriber). Method("Clearing"); this.observedCollection.Clear(); + this.mockery.VerifyAllExpectationsHaveBeenMet(); } /// Tests whether the ItemAdded event is fired [Test] public void TestItemAddedEvent() { - Expect.Once.On(this.mockedSubscriber). Method("ItemAdded"). WithAnyArguments(); this.observedCollection.Add(123); + this.mockery.VerifyAllExpectationsHaveBeenMet(); } /// Tests whether the ItemRemoved event is fired [Test] public void TestItemRemovedEvent() { - Expect.Once.On(this.mockedSubscriber). Method("ItemAdded"). WithAnyArguments(); @@ -89,6 +90,7 @@ namespace Nuclex.Support.Collections { this.observedCollection.Add(123); this.observedCollection.Remove(123); + this.mockery.VerifyAllExpectationsHaveBeenMet(); } /// Mock object factory diff --git a/Source/Tracking/Internal/ObservedProgression.cs b/Source/Tracking/Internal/ObservedProgression.cs index 0da1b75..aab0d8d 100644 --- a/Source/Tracking/Internal/ObservedProgression.cs +++ b/Source/Tracking/Internal/ObservedProgression.cs @@ -8,34 +8,105 @@ namespace Nuclex.Support.Tracking { /// /// Type of the progression that is being observed /// - internal class ObservedProgression + internal class ObservedProgression : IDisposable where ProgressionType : Progression { + /// Delegate for reporting progress updates + public delegate void ReportDelegate(); + /// Initializes a new observed progression /// Weighted progression being observed + /// + /// Callback to invoke when the progression's progress changes + /// + /// + /// Callback to invoke when the progression has ended + /// internal ObservedProgression( - WeightedProgression weightedProgression + WeightedProgression weightedProgression, + ReportDelegate progressUpdateCallback, + ReportDelegate endedCallback ) { this.weightedProgression = weightedProgression; + this.endedCallback = endedCallback; + this.progressUpdateCallback = progressUpdateCallback; + + this.weightedProgression.Progression.AsyncEnded += + new EventHandler(asyncEnded); + + this.weightedProgression.Progression.AsyncProgressUpdated += + new EventHandler(asyncProgressUpdated); } - + + /// Immediately releases all resources owned by the object + public void Dispose() { + disconnectEvents(); + } + /// Weighted progression being observed public WeightedProgression WeightedProgression { get { return this.weightedProgression; } } -/* - internal void AsyncProgressUpdated(object sender, ProgressUpdateEventArgs e) { - this.Progress = e.Progress; + /// Amount of progress this progression has achieved so far + public float Progress { + get { return this.progress; } } - internal void AsyncEnded(object sender, EventArgs e) { } -*/ + + /// Called when the observed progression has ended + /// Progression that has ended + /// Not used + private void asyncEnded(object sender, EventArgs e) { + this.progress = 1.0f; + + disconnectEvents(); // We don't need those anymore! + + this.progressUpdateCallback(); + } + + /// Called when the progress of the observed progression changes + /// Progression whose progress has changed + /// Contains the updated progress + private void asyncProgressUpdated(object sender, ProgressUpdateEventArgs e) { + this.progress = e.Progress; + + this.progressUpdateCallback(); + } + + /// Unscribes from all events of the observed progression + private void disconnectEvents() { + + // Make use of the double check locking idiom to avoid the costly lock when + // the events have already been unsubscribed + if(this.endedCallback != null) { + + // This is an internal class with special knowledge that there + // is no risk of deadlock involved, so we don't need a fancy syncRoot! + lock(this) { + if(this.endedCallback != null) { + this.weightedProgression.Progression.AsyncEnded -= + new EventHandler(asyncEnded); + + this.weightedProgression.Progression.AsyncProgressUpdated -= + new EventHandler(asyncProgressUpdated); + + this.endedCallback = null; + this.progressUpdateCallback = null; + } + } + + } // endedCallback != null + + } + /// The weighted progression that is being observed private WeightedProgression weightedProgression; -/* - /// Amount of progress this progression has achieved so far + /// Callback to invoke when the progress updates + private volatile ReportDelegate progressUpdateCallback; + /// Callback to invoke when the progression ends + private volatile ReportDelegate endedCallback; + /// Progress achieved so far private volatile float progress; -*/ } } // namespace Nuclex.Support.Tracking diff --git a/Source/Tracking/Internal/WeightedProgressionWrapperCollection.cs b/Source/Tracking/Internal/WeightedProgressionWrapperCollection.cs index 97e6598..550cad9 100644 --- a/Source/Tracking/Internal/WeightedProgressionWrapperCollection.cs +++ b/Source/Tracking/Internal/WeightedProgressionWrapperCollection.cs @@ -27,7 +27,7 @@ namespace Nuclex.Support.Tracking { /// SetProgression or QueueOperation classes was created. /// /// - internal class WeightedProgressionCollection : + internal class WeightedProgressionWrapperCollection : TransformingReadOnlyCollection< ObservedProgression, WeightedProgression > @@ -35,7 +35,7 @@ namespace Nuclex.Support.Tracking { /// Initializes a new weighted progression collection wrapper /// Items to be exposed as weighted progressions - internal WeightedProgressionCollection( + internal WeightedProgressionWrapperCollection( IList> items ) : base(items) { } diff --git a/Source/Tracking/ProgressUpdateEventArgs.cs b/Source/Tracking/ProgressUpdateEventArgs.cs index 7e50889..c44a8d0 100644 --- a/Source/Tracking/ProgressUpdateEventArgs.cs +++ b/Source/Tracking/ProgressUpdateEventArgs.cs @@ -19,7 +19,7 @@ namespace Nuclex.Support.Tracking { } /// Achieved progress - protected float progress; + private float progress; } diff --git a/Source/Tracking/Progression.cs b/Source/Tracking/Progression.cs index 66c0447..05a20cd 100644 --- a/Source/Tracking/Progression.cs +++ b/Source/Tracking/Progression.cs @@ -52,17 +52,21 @@ namespace Nuclex.Support.Tracking { public event EventHandler AsyncEnded; /// Whether the progression has ended already - public virtual bool Ended { + public bool Ended { get { return this.ended; } } /// WaitHandle that can be used to wait for the progression to end public WaitHandle WaitHandle { get { - + // The WaitHandle will only be created when someone asks for it! // See the Double-Check Locking idiom on why the condition is checked twice // (primarily, it avoids an expensive lock when it isn't needed) + // + // We can *not* optimize this lock away since we absolutely must not create + // two doneEvents -- someone might call .WaitOne() on the first one when only + // the second one is assigned to this.doneEvent and gets set in the end. if(this.doneEvent == null) { lock(this.syncRoot) { @@ -109,16 +113,27 @@ namespace Nuclex.Support.Tracking { /// seperately. /// protected virtual void OnAsyncEnded() { + + // TODO: Find a way around this. Interlocked.Exchange would be best! + // We do not lock here since we require this method to be called only once + // in the object's lifetime. If someone really badly wanted to break this + // he'd probably have a one-in-a-million chance of getting through. + if(this.ended) + throw new InvalidOperationException("The progression has already been ended"); + this.ended = true; - lock(this.syncRoot) { - if(this.doneEvent != null) - this.doneEvent.Set(); - } + // Doesn't need a lock. If another thread wins the race and creates the event + // after we just saw it being null, it would be created in an already set + // state due to the ended flag (see above) being set to true beforehand! + if(this.doneEvent != null) + this.doneEvent.Set(); + // Finally, fire the AsyncEnded event EventHandler copy = AsyncEnded; if(copy != null) copy(this, EventArgs.Empty); + } /// Used to synchronize multithreaded accesses to this object diff --git a/Source/Tracking/SetProgression.Test.cs b/Source/Tracking/SetProgression.Test.cs new file mode 100644 index 0000000..f39658f --- /dev/null +++ b/Source/Tracking/SetProgression.Test.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.IO; + +#if UNITTEST + +using NUnit.Framework; +using NMock2; + +namespace Nuclex.Support.Tracking { + + /// Unit Test for the progression set class + [TestFixture] + public class SetProgressionTest { + + #region interface ISetProgressionSubscriber + + /// Interface used to test the set progression. + public interface ISetProgressionSubscriber { + + /// Called when the set progression's progress changes + /// Set progression whose progress has changed + /// Contains the new progress achieved + void ProgressUpdated(object sender, ProgressUpdateEventArgs e); + + /// Called when the set progression has ended + /// Set progression that as ended + /// Not used + void Ended(object sender, EventArgs e); + + } + + #endregion // interface ISetProgressionSubscriber + + #region class ProgressUpdateEventArgsMatcher + + /// Compares two ProgressUpdateEventArgsInstances for NMock validation + private class ProgressUpdateEventArgsMatcher : Matcher { + + /// Initializes a new ProgressUpdateEventArgsMatcher + /// Expected progress update event arguments + public ProgressUpdateEventArgsMatcher(ProgressUpdateEventArgs expected) { + this.expected = expected; + } + + /// + /// Called by NMock to verfiy the ProgressUpdateEventArgs match the expected value + /// + /// Actual value to compare to the expected value + /// + /// True if the actual value matches the expected value; otherwise false + /// + public override bool Matches(object actualAsObject) { + ProgressUpdateEventArgs actual = (actualAsObject as ProgressUpdateEventArgs); + if(actual == null) + return false; + + return (actual.Progress == this.expected.Progress); + } + + /// Creates a string representation of the expected value + /// Writer to write the string representation into + public override void DescribeTo(TextWriter writer) { + writer.Write(this.expected.Progress.ToString()); + } + + /// Expected progress update event args value + private ProgressUpdateEventArgs expected; + + } + + #endregion // class ProgressUpdateEventArgsMatcher + + #region class TestProgression + + /// Progression used for testing in this unit test + private class TestProgression : Progression { + + /// Changes the testing progression's indicated progress + /// + /// New progress to be reported by the testing progression + /// + public void ChangeProgress(float progress) { + OnAsyncProgressUpdated(progress); + } + + /// Transitions the progression into the ended state + public void End() { + OnAsyncEnded(); + } + + } + + #endregion // class TestProgression + + /// Initialization routine executed before each test is run + [SetUp] + public void Setup() { + this.mockery = new Mockery(); + } + + /// Validates that the set progression properly sums the progress + [Test] + public void TestSummedProgress() { + SetProgression testSetProgression = + new SetProgression( + new TestProgression[] { + new TestProgression(), new TestProgression() + } + ); + + ISetProgressionSubscriber mockedSubscriber = mockSubscriber(testSetProgression); + + Expect.Once.On(mockedSubscriber). + Method("ProgressUpdated"). + With( + new Matcher[] { + new NMock2.Matchers.TypeMatcher(typeof(SetProgression)), + new ProgressUpdateEventArgsMatcher(new ProgressUpdateEventArgs(0.25f)) + } + ); + + testSetProgression.Childs[0].Progression.ChangeProgress(0.5f); + + this.mockery.VerifyAllExpectationsHaveBeenMet(); + } + + /// Validates that the set progression respects the weights + [Test] + public void TestWeightedSummedProgress() { + SetProgression testSetProgression = + new SetProgression( + new WeightedProgression[] { + new WeightedProgression(new TestProgression(), 1.0f), + new WeightedProgression(new TestProgression(), 2.0f) + } + ); + + ISetProgressionSubscriber mockedSubscriber = mockSubscriber(testSetProgression); + + Expect.Once.On(mockedSubscriber). + Method("ProgressUpdated"). + With( + new Matcher[] { + new NMock2.Matchers.TypeMatcher(typeof(SetProgression)), + new ProgressUpdateEventArgsMatcher(new ProgressUpdateEventArgs(0.5f / 3.0f)) + } + ); + + testSetProgression.Childs[0].Progression.ChangeProgress(0.5f); + + Expect.Once.On(mockedSubscriber). + Method("ProgressUpdated"). + With( + new Matcher[] { + new NMock2.Matchers.TypeMatcher(typeof(SetProgression)), + new ProgressUpdateEventArgsMatcher(new ProgressUpdateEventArgs(0.5f)) + } + ); + + testSetProgression.Childs[1].Progression.ChangeProgress(0.5f); + + this.mockery.VerifyAllExpectationsHaveBeenMet(); + } + + /// Mocks a subscriber for the events of a progression + /// Progression to mock an event subscriber for + /// The mocked event subscriber + private ISetProgressionSubscriber mockSubscriber(Progression progression) { + ISetProgressionSubscriber mockedSubscriber = + this.mockery.NewMock(); + + progression.AsyncEnded += new EventHandler(mockedSubscriber.Ended); + progression.AsyncProgressUpdated += + new EventHandler(mockedSubscriber.ProgressUpdated); + + return mockedSubscriber; + } + + /// + /// Validates that the ended event is triggered when the last progression ends + /// + [Test] + public void TestEndedEvent() { + SetProgression testSetProgression = + new SetProgression( + new TestProgression[] { + new TestProgression(), new TestProgression() + } + ); + + ISetProgressionSubscriber mockedSubscriber = mockSubscriber(testSetProgression); + + Expect.Exactly(2).On(mockedSubscriber). + Method("ProgressUpdated"). + WithAnyArguments(); + + Expect.Once.On(mockedSubscriber). + Method("Ended"). + WithAnyArguments(); + + testSetProgression.Childs[0].Progression.End(); + testSetProgression.Childs[1].Progression.End(); + + this.mockery.VerifyAllExpectationsHaveBeenMet(); + } + + /// Mock object factory + private Mockery mockery; + + } + +} // namespace Nuclex.Support.Tracking + +#endif // UNITTEST diff --git a/Source/Tracking/SetProgression.cs b/Source/Tracking/SetProgression.cs index 29bdba0..c2ce6da 100644 --- a/Source/Tracking/SetProgression.cs +++ b/Source/Tracking/SetProgression.cs @@ -11,31 +11,139 @@ namespace Nuclex.Support.Tracking { public class SetProgression : Progression where ProgressionType : Progression { + /// Performs common initialization for the public constructors + private SetProgression() { + this.childs = new List>(); + this.asyncProgressUpdatedDelegate = + new ObservedProgression.ReportDelegate(asyncProgressUpdated); + this.asyncEndedDelegate = + new ObservedProgression.ReportDelegate(asyncEnded); + } + /// Initializes a new set progression - /// Progressions to track with this set + /// Progressions to track with this set /// /// Uses a default weighting factor of 1.0 for all progressions. /// - public SetProgression(IEnumerable progressions) { + public SetProgression(IEnumerable childs) + : this() { + + // Construct a WeightedProgression with the default weight for each + // progression and wrap it in an ObservedProgression + foreach(ProgressionType progression in childs) { + this.childs.Add( + new ObservedProgression( + new WeightedProgression(progression), + this.asyncProgressUpdatedDelegate, this.asyncEndedDelegate + ) + ); + } + + // Since all progressions have a weight of 1.0, the total weight is + // equal to the number of progressions in our list + this.totalWeight = (float)this.childs.Count; + } /// Initializes a new set progression - /// Progressions to track with this set - public SetProgression(IEnumerable> progressions) { + /// Progressions to track with this set + public SetProgression( + IEnumerable> childs + ) + : this() { + + // Construct an ObservedProgression around each of the WeightedProgressions + float totalWeight = 0.0f; + foreach(WeightedProgression progression in childs) { + this.childs.Add( + new ObservedProgression( + progression, + this.asyncProgressUpdatedDelegate, this.asyncEndedDelegate + ) + ); + + // Sum up the total weight + totalWeight += progression.Weight; + } + + // Take over the summed weight of all progressions we were given + this.totalWeight = totalWeight; + } /// Childs contained in the progression set - public ReadOnlyCollection> Childs { - get { return null; } + public IList> Childs { + get { + + // The wrapper is constructed only when needed. Most of the time, users will + // just create a SetProgression and monitor its progress without ever using + // the Childs collection. + if(this.wrapper == null) { + + // This doesn't need a lock because it's only a stateless wrapper. If it + // is constructed twice, then so be it. + this.wrapper = new WeightedProgressionWrapperCollection( + this.childs + ); + + } + + return this.wrapper; + + } } -/* + + /// + /// Called when the progress of one of the observed progressions changes + /// + private void asyncProgressUpdated() { + + // Calculate the sum of the progress reported by our child progressions, + // scaled to the weight each progression has assigned to it. + float totalProgress = 0.0f; + for(int index = 0; index < this.childs.Count; ++index) { + totalProgress += + this.childs[index].Progress * this.childs[index].WeightedProgression.Weight; + } + + // Calculate the actual combined progress + if(this.totalWeight > 0.0f) + totalProgress /= this.totalWeight; + + // Send out the progress update + OnAsyncProgressUpdated(totalProgress); + + } + + /// + /// Called when an observed progressions ends + /// + private void asyncEnded() { + + for(int index = 0; index < this.childs.Count; ++index) + if(!this.childs[index].WeightedProgression.Progression.Ended) + return; + + OnAsyncEnded(); + + } + /// Progressions being managed in the set - private ReadOnlyCollection progressions; - /// whether the progress needs to be recalculated - private volatile bool needProgressRecalculation; - /// Total progress achieved by the progressions in this collection - private volatile float totalProgress; -*/ + private List> childs; + /// + /// Wrapper collection for exposing the child progressions under the + /// WeightedProgression interface + /// + private volatile WeightedProgressionWrapperCollection wrapper; + /// Summed weight of all progression in the set + private float totalWeight; + + /// Delegate for the asyncProgressUpdated() method + private ObservedProgression.ReportDelegate asyncProgressUpdatedDelegate; + /// Delegate for the asyncEnded() method + private ObservedProgression.ReportDelegate asyncEndedDelegate; + + } } // namespace Nuclex.Support.Tracking