From 8ef5d08bb4926769d9208032d86a7be483d61b70 Mon Sep 17 00:00:00 2001 From: Ze Groumf Date: Thu, 19 Mar 2020 22:01:34 +0100 Subject: [PATCH 01/13] first drop --- Microsoft.Toolkit/Microsoft.Toolkit.csproj | 4 + .../Observables/ObservableGroup.cs | 53 ++++++++ .../ObservableGroupedCollection.cs | 34 ++++++ .../Observables/ReadOnlyObservableGroup.cs | 41 +++++++ .../ReadOnlyObservableGroupedCollection.cs | 115 ++++++++++++++++++ 5 files changed, 247 insertions(+) create mode 100644 Microsoft.Toolkit/Observables/ObservableGroup.cs create mode 100644 Microsoft.Toolkit/Observables/ObservableGroupedCollection.cs create mode 100644 Microsoft.Toolkit/Observables/ReadOnlyObservableGroup.cs create mode 100644 Microsoft.Toolkit/Observables/ReadOnlyObservableGroupedCollection.cs diff --git a/Microsoft.Toolkit/Microsoft.Toolkit.csproj b/Microsoft.Toolkit/Microsoft.Toolkit.csproj index 011f95377f7..fe2846fe086 100644 --- a/Microsoft.Toolkit/Microsoft.Toolkit.csproj +++ b/Microsoft.Toolkit/Microsoft.Toolkit.csproj @@ -67,4 +67,8 @@ + + + + \ No newline at end of file diff --git a/Microsoft.Toolkit/Observables/ObservableGroup.cs b/Microsoft.Toolkit/Observables/ObservableGroup.cs new file mode 100644 index 00000000000..0a901eb3da3 --- /dev/null +++ b/Microsoft.Toolkit/Observables/ObservableGroup.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Microsoft.Toolkit.Observables.Collections +{ + /// + /// An observable group. It associates a to an . + /// + /// The type of the group key. + /// The type of the items in the collection. + public sealed class ObservableGroup : ObservableCollection, IGrouping + { + /// + /// Initializes a new instance of the class. + /// + /// The key for the group. + public ObservableGroup(TKey key) + { + Key = key; + } + + /// + /// Initializes a new instance of the class. + /// + /// The grouping to fill the group. + public ObservableGroup(IGrouping grouping) + : base(grouping) + { + Key = grouping.Key; + } + + /// + /// Initializes a new instance of the class. + /// + /// The key for the group. + /// The initial collection of data to add to the group. + public ObservableGroup(TKey key, IEnumerable collection) + : base(collection) + { + Key = key; + } + + /// + /// The key of the group. + /// + public TKey Key { get; } + } +} diff --git a/Microsoft.Toolkit/Observables/ObservableGroupedCollection.cs b/Microsoft.Toolkit/Observables/ObservableGroupedCollection.cs new file mode 100644 index 00000000000..79e7ef7c5d0 --- /dev/null +++ b/Microsoft.Toolkit/Observables/ObservableGroupedCollection.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Microsoft.Toolkit.Observables.Collections +{ + /// + /// An observable list of observable groups. + /// + /// The type of the group key. + /// The type of the items in the collection. + public sealed class ObservableGroupedCollection : ObservableCollection> + { + /// + /// Initializes a new instance of the class. + /// + public ObservableGroupedCollection() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The initial data to add in the grouped collection. + public ObservableGroupedCollection(IEnumerable> collection) + : base(collection.Select(c => new ObservableGroup(c.Key, c))) + { + } + } +} diff --git a/Microsoft.Toolkit/Observables/ReadOnlyObservableGroup.cs b/Microsoft.Toolkit/Observables/ReadOnlyObservableGroup.cs new file mode 100644 index 00000000000..b0726e0b929 --- /dev/null +++ b/Microsoft.Toolkit/Observables/ReadOnlyObservableGroup.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; +using System.Linq; + +namespace Microsoft.Toolkit.Observables.Collections +{ + /// + /// A read-only observable group. It associates a to a . + /// + /// The type of the group key. + /// The type of the items in the collection. + public sealed class ReadOnlyObservableGroup : ReadOnlyObservableCollection, IGrouping + { + /// + /// Initializes a new instance of the class. + /// + /// The key of the group. + /// The collection of items to add in the group. + public ReadOnlyObservableGroup(TKey key, ObservableCollection collection) + : base(collection) + { + Key = key; + } + + /// + /// Initializes a new instance of the class. + /// + /// The to wrap. + public ReadOnlyObservableGroup(ObservableGroup group) + : base(group) + { + Key = group.Key; + } + + /// + public TKey Key { get; } + } +} diff --git a/Microsoft.Toolkit/Observables/ReadOnlyObservableGroupedCollection.cs b/Microsoft.Toolkit/Observables/ReadOnlyObservableGroupedCollection.cs new file mode 100644 index 00000000000..74ca7c60fca --- /dev/null +++ b/Microsoft.Toolkit/Observables/ReadOnlyObservableGroupedCollection.cs @@ -0,0 +1,115 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.Toolkit.Observables.Collections +{ + /// + /// A read-only list of groups. + /// + /// The type of the group key. + /// The type of the items in the collection. + public sealed class ReadOnlyObservableGroupedCollection : + IReadOnlyCollection>, + IReadOnlyList>, + INotifyPropertyChanged, + INotifyCollectionChanged + { + private readonly ObservableGroupedCollection _collection; + private readonly IDictionary, ReadOnlyObservableGroup> _mapping; + + /// + public event NotifyCollectionChangedEventHandler CollectionChanged; + + /// + public event PropertyChangedEventHandler PropertyChanged; + + /// + /// Initializes a new instance of the class. + /// + /// The source collection to wrap. + public ReadOnlyObservableGroupedCollection(ObservableGroupedCollection collection) + { + _collection = collection; + _mapping = new Dictionary, ReadOnlyObservableGroup>(capacity: _collection.Count); + + ((INotifyPropertyChanged)_collection).PropertyChanged += OnCollectionPropertyChanged; + ((INotifyCollectionChanged)_collection).CollectionChanged += OnCollectionChanged; + } + + /// + public ReadOnlyObservableGroup this[int index] => CreateOrGetReadOnlyObservableGroup(_collection[index]); + + /// + public int Count => _collection.Count; + + /// + public IEnumerator> GetEnumerator() => _collection.Select(CreateOrGetReadOnlyObservableGroup).GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_collection.Select(CreateOrGetReadOnlyObservableGroup)).GetEnumerator(); + + private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + // We force the evaluation to have all our instances ready before deleting the mapping. + var sourceOldItems = e.OldItems?.Cast>() ?? Enumerable.Empty>(); + var oldItems = (IList)sourceOldItems.Select(CreateOrGetReadOnlyObservableGroup).ToList(); + var newItems = (IList)(e.NewItems?.Cast>().Select(CreateOrGetReadOnlyObservableGroup) ?? Enumerable.Empty>()).ToList(); + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, newItems)); + break; + case NotifyCollectionChangedAction.Move: + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Move, newItems, e.NewStartingIndex, e.OldStartingIndex)); + break; + case NotifyCollectionChangedAction.Remove: + // We unmap the removed or replaced items. + foreach (var sourceOldItem in sourceOldItems) + { + _mapping.Remove(sourceOldItem); + } + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, oldItems)); + break; + case NotifyCollectionChangedAction.Replace: + // We unmap the removed or replaced items. + foreach (var sourceOldItem in sourceOldItems) + { + _mapping.Remove(sourceOldItem); + } + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, newItems, oldItems)); + break; + case NotifyCollectionChangedAction.Reset: + _mapping.Clear(); + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + break; + default: + Debug.Fail("unsupported value"); + break; + } + } + + private void OnCollectionPropertyChanged(object sender, PropertyChangedEventArgs e) => PropertyChanged?.Invoke(this, e); + + private ReadOnlyObservableGroup CreateOrGetReadOnlyObservableGroup(ObservableGroup observableGroup) + { + if (_mapping.TryGetValue(observableGroup, out var readOnlyGroup)) + { + return readOnlyGroup; + } + + readOnlyGroup = new ReadOnlyObservableGroup(observableGroup); + _mapping.Add(observableGroup, readOnlyGroup); + + return readOnlyGroup; + } + } +} From 2b67afac4ccf1f1a2c133ec6280cebfaa698476d Mon Sep 17 00:00:00 2001 From: Ze Groumf Date: Thu, 19 Mar 2020 23:59:36 +0100 Subject: [PATCH 02/13] add unit tests --- .../Observables/ObservableGroup.cs | 5 +- UnitTests/Observables/IntGroup.cs | 20 ++ UnitTests/Observables/ObservableGroupTests.cs | 111 ++++++++ .../ObservableGroupedCollectionTests.cs | 43 ++++ .../ReadOnlyObservableGroupTests.cs | 107 ++++++++ ...eadOnlyObservableGroupedCollectionTests.cs | 240 ++++++++++++++++++ UnitTests/UnitTests.csproj | 8 + 7 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 UnitTests/Observables/IntGroup.cs create mode 100644 UnitTests/Observables/ObservableGroupTests.cs create mode 100644 UnitTests/Observables/ObservableGroupedCollectionTests.cs create mode 100644 UnitTests/Observables/ReadOnlyObservableGroupTests.cs create mode 100644 UnitTests/Observables/ReadOnlyObservableGroupedCollectionTests.cs diff --git a/Microsoft.Toolkit/Observables/ObservableGroup.cs b/Microsoft.Toolkit/Observables/ObservableGroup.cs index 0a901eb3da3..f7a965d4965 100644 --- a/Microsoft.Toolkit/Observables/ObservableGroup.cs +++ b/Microsoft.Toolkit/Observables/ObservableGroup.cs @@ -9,7 +9,8 @@ namespace Microsoft.Toolkit.Observables.Collections { /// - /// An observable group. It associates a to an . + /// An observable group. + /// It associates a to an . /// /// The type of the group key. /// The type of the items in the collection. @@ -46,7 +47,7 @@ public ObservableGroup(TKey key, IEnumerable collection) } /// - /// The key of the group. + /// Gets the key of the group. /// public TKey Key { get; } } diff --git a/UnitTests/Observables/IntGroup.cs b/UnitTests/Observables/IntGroup.cs new file mode 100644 index 00000000000..d0a6734c160 --- /dev/null +++ b/UnitTests/Observables/IntGroup.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Linq; + +namespace UnitTests.Observables +{ + public class IntGroup : List, IGrouping + { + public IntGroup(string key, IEnumerable collection) + : base(collection) + { + Key = key; + } + + public string Key { get; } + } +} diff --git a/UnitTests/Observables/ObservableGroupTests.cs b/UnitTests/Observables/ObservableGroupTests.cs new file mode 100644 index 00000000000..39e5cfd49a1 --- /dev/null +++ b/UnitTests/Observables/ObservableGroupTests.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using FluentAssertions; +using Microsoft.Toolkit.Observables.Collections; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Specialized; + +namespace UnitTests.Observables +{ + [TestClass] + public class ObservableGroupTests + { + [TestCategory("Observables")] + [TestMethod] + public void Ctor_ShouldHaveExpectedState() + { + var group = new ObservableGroup("key"); + + group.Key.Should().Be("key"); + group.Should().BeEmpty(); + } + + [TestCategory("Observables")] + [TestMethod] + public void Ctor_WithGrouping_ShouldHaveExpectedState() + { + var source = new IntGroup("Key", new[] { 1, 2, 3 }); + var group = new ObservableGroup(source); + + group.Key.Should().Be("Key"); + group.Should().BeEquivalentTo(new[] { 1, 2, 3 }, option => option.WithStrictOrdering()); + } + + [TestCategory("Observables")] + [TestMethod] + public void Ctor_WithCollection_ShouldHaveExpectedState() + { + var source = new[] { 1, 2, 3 }; + var group = new ObservableGroup("key", source); + + group.Key.Should().Be("key"); + group.Should().BeEquivalentTo(new[] { 1, 2, 3 }, option => option.WithStrictOrdering()); + } + + [TestCategory("Observables")] + [TestMethod] + public void Add_ShouldRaiseEvent() + { + var collectionChangedEventRaised = false; + var source = new[] { 1, 2, 3 }; + var group = new ObservableGroup("key", source); + ((INotifyCollectionChanged)group).CollectionChanged += (s, e) => collectionChangedEventRaised = true; + + group.Add(4); + + group.Key.Should().Be("key"); + group.Should().BeEquivalentTo(new[] { 1, 2, 3, 4 }, option => option.WithStrictOrdering()); + collectionChangedEventRaised.Should().BeTrue(); + } + + [TestCategory("Observables")] + [TestMethod] + public void Update_ShouldRaiseEvent() + { + var collectionChangedEventRaised = false; + var source = new[] { 1, 2, 3 }; + var group = new ObservableGroup("key", source); + ((INotifyCollectionChanged)group).CollectionChanged += (s, e) => collectionChangedEventRaised = true; + + group[1] = 4; + + group.Key.Should().Be("key"); + group.Should().BeEquivalentTo(new[] { 1, 4, 3 }, option => option.WithStrictOrdering()); + collectionChangedEventRaised.Should().BeTrue(); + } + + [TestCategory("Observables")] + [TestMethod] + public void Remove_ShouldRaiseEvent() + { + var collectionChangedEventRaised = false; + var source = new[] { 1, 2, 3 }; + var group = new ObservableGroup("key", source); + ((INotifyCollectionChanged)group).CollectionChanged += (s, e) => collectionChangedEventRaised = true; + + group.Remove(1); + + group.Key.Should().Be("key"); + group.Should().BeEquivalentTo(new[] { 2, 3 }, option => option.WithStrictOrdering()); + collectionChangedEventRaised.Should().BeTrue(); + } + + [TestCategory("Observables")] + [TestMethod] + public void Clear_ShouldRaiseEvent() + { + var collectionChangedEventRaised = false; + var source = new[] { 1, 2, 3 }; + var group = new ObservableGroup("key", source); + ((INotifyCollectionChanged)group).CollectionChanged += (s, e) => collectionChangedEventRaised = true; + + group.Clear(); + + group.Key.Should().Be("key"); + group.Should().BeEmpty(); + collectionChangedEventRaised.Should().BeTrue(); + } + } +} diff --git a/UnitTests/Observables/ObservableGroupedCollectionTests.cs b/UnitTests/Observables/ObservableGroupedCollectionTests.cs new file mode 100644 index 00000000000..c43d14dc072 --- /dev/null +++ b/UnitTests/Observables/ObservableGroupedCollectionTests.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using FluentAssertions; +using Microsoft.Toolkit.Observables.Collections; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.Linq; + +namespace UnitTests.Observables +{ + [TestClass] + public class ObservableGroupedCollectionTests + { + [TestCategory("Observables")] + [TestMethod] + public void Ctor_ShouldHaveExpectedValues() + { + var groupCollection = new ObservableGroupedCollection(); + + groupCollection.Should().BeEmpty(); + } + + [TestCategory("Observables")] + [TestMethod] + public void Ctor_WithGroups_ShouldHaveExpectedValues() + { + var groups = new List> + { + new IntGroup("A", new[] { 1, 3, 5 }), + new IntGroup("B", new[] { 2, 4, 6 }), + }; + var groupCollection = new ObservableGroupedCollection(groups); + + groupCollection.Should().HaveCount(2); + groupCollection.ElementAt(0).Key.Should().Be("A"); + groupCollection.ElementAt(0).Should().BeEquivalentTo(new[] { 1, 3, 5 }, o => o.WithStrictOrdering()); + groupCollection.ElementAt(1).Key.Should().Be("B"); + groupCollection.ElementAt(1).Should().BeEquivalentTo(new[] { 2, 4, 6 }, o => o.WithStrictOrdering()); + } + } +} diff --git a/UnitTests/Observables/ReadOnlyObservableGroupTests.cs b/UnitTests/Observables/ReadOnlyObservableGroupTests.cs new file mode 100644 index 00000000000..667a4a4db88 --- /dev/null +++ b/UnitTests/Observables/ReadOnlyObservableGroupTests.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using FluentAssertions; +using Microsoft.Toolkit.Observables.Collections; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.ObjectModel; +using System.Collections.Specialized; + +namespace UnitTests.Observables +{ + [TestClass] + public class ReadOnlyObservableGroupTests + { + [TestCategory("Observables")] + [TestMethod] + public void Ctor_WithKeyAndCollection_ShouldHaveExpectedInitialState() + { + var source = new ObservableCollection(new[] { 1, 2, 3 }); + var group = new ReadOnlyObservableGroup("key", source); + + group.Key.Should().Be("key"); + group.Should().BeEquivalentTo(new[] { 1, 2, 3 }, option => option.WithStrictOrdering()); + } + + [TestCategory("Observables")] + [TestMethod] + public void Ctor_ObservableGroup_ShouldHaveExpectedInitialState() + { + var source = new[] { 1, 2, 3 }; + var sourceGroup = new ObservableGroup("key", source); + var group = new ReadOnlyObservableGroup(sourceGroup); + + group.Key.Should().Be("key"); + group.Should().BeEquivalentTo(new[] { 1, 2, 3 }, option => option.WithStrictOrdering()); + } + + [TestCategory("Observables")] + [TestMethod] + public void Add_ShouldRaiseEvent() + { + var collectionChangedEventRaised = false; + var source = new[] { 1, 2, 3 }; + var sourceGroup = new ObservableGroup("key", source); + var group = new ReadOnlyObservableGroup(sourceGroup); + ((INotifyCollectionChanged)group).CollectionChanged += (s, e) => collectionChangedEventRaised = true; + + sourceGroup.Add(4); + + group.Key.Should().Be("key"); + group.Should().BeEquivalentTo(new[] { 1, 2, 3, 4 }, option => option.WithStrictOrdering()); + collectionChangedEventRaised.Should().BeTrue(); + } + + [TestCategory("Observables")] + [TestMethod] + public void Update_ShouldRaiseEvent() + { + var collectionChangedEventRaised = false; + var source = new[] { 1, 2, 3 }; + var sourceGroup = new ObservableGroup("key", source); + var group = new ReadOnlyObservableGroup(sourceGroup); + ((INotifyCollectionChanged)group).CollectionChanged += (s, e) => collectionChangedEventRaised = true; + + sourceGroup[1] = 4; + + group.Key.Should().Be("key"); + group.Should().BeEquivalentTo(new[] { 1, 4, 3 }, option => option.WithStrictOrdering()); + collectionChangedEventRaised.Should().BeTrue(); + } + + [TestCategory("Observables")] + [TestMethod] + public void Remove_ShouldRaiseEvent() + { + var collectionChangedEventRaised = false; + var source = new[] { 1, 2, 3 }; + var sourceGroup = new ObservableGroup("key", source); + var group = new ReadOnlyObservableGroup(sourceGroup); + ((INotifyCollectionChanged)group).CollectionChanged += (s, e) => collectionChangedEventRaised = true; + + sourceGroup.Remove(1); + + group.Key.Should().Be("key"); + group.Should().BeEquivalentTo(new[] { 2, 3 }, option => option.WithStrictOrdering()); + collectionChangedEventRaised.Should().BeTrue(); + } + + [TestCategory("Observables")] + [TestMethod] + public void Clear_ShouldRaiseEvent() + { + var collectionChangedEventRaised = false; + var source = new[] { 1, 2, 3 }; + var sourceGroup = new ObservableGroup("key", source); + var group = new ReadOnlyObservableGroup(sourceGroup); + ((INotifyCollectionChanged)group).CollectionChanged += (s, e) => collectionChangedEventRaised = true; + + sourceGroup.Clear(); + + group.Key.Should().Be("key"); + group.Should().BeEmpty(); + collectionChangedEventRaised.Should().BeTrue(); + } + } +} diff --git a/UnitTests/Observables/ReadOnlyObservableGroupedCollectionTests.cs b/UnitTests/Observables/ReadOnlyObservableGroupedCollectionTests.cs new file mode 100644 index 00000000000..8395f96e822 --- /dev/null +++ b/UnitTests/Observables/ReadOnlyObservableGroupedCollectionTests.cs @@ -0,0 +1,240 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using FluentAssertions; +using Microsoft.Toolkit.Observables.Collections; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; + +namespace UnitTests.Observables +{ + [TestClass] + public class ReadOnlyObservableGroupedCollectionTests + { + [TestCategory("Observables")] + [TestMethod] + public void Ctor_WithEmptySource_ShoudInitializeObject() + { + var source = new ObservableGroupedCollection(); + var readOnlyGroup = new ReadOnlyObservableGroupedCollection(source); + + readOnlyGroup.Should().BeEmpty(); + readOnlyGroup.Count.Should().Be(0); + } + + [TestCategory("Observables")] + [TestMethod] + public void Ctor_WithSource_ShoudInitializeObject() + { + var groups = new List> + { + new IntGroup("A", new[] { 1, 3, 5 }), + new IntGroup("B", new[] { 2, 4, 6 }), + }; + var source = new ObservableGroupedCollection(groups); + var readOnlyGroup = new ReadOnlyObservableGroupedCollection(source); + + readOnlyGroup.Should().HaveCount(2); + readOnlyGroup.Count.Should().Be(2); + readOnlyGroup.ElementAt(0).Key.Should().Be("A"); + readOnlyGroup.ElementAt(0).Should().BeEquivalentTo(new[] { 1, 3, 5 }, o => o.WithoutStrictOrdering()); + readOnlyGroup.ElementAt(1).Key.Should().Be("B"); + readOnlyGroup.ElementAt(1).Should().BeEquivalentTo(new[] { 2, 4, 6 }, o => o.WithoutStrictOrdering()); + } + + [TestCategory("Observables")] + [TestMethod] + public void AddGroupInSource_ShouldAddGroup() + { + NotifyCollectionChangedEventArgs collectionChangedEventArgs = null; + var isCountPropertyChangedEventRaised = false; + var itemsList = new[] { 1, 2, 3 }; + var source = new ObservableGroupedCollection(); + var readOnlyGroup = new ReadOnlyObservableGroupedCollection(source); + ((INotifyCollectionChanged)readOnlyGroup).CollectionChanged += (s, e) => collectionChangedEventArgs = e; + ((INotifyPropertyChanged)readOnlyGroup).PropertyChanged += (s, e) => isCountPropertyChangedEventRaised = isCountPropertyChangedEventRaised || e.PropertyName == nameof(readOnlyGroup.Count); + + source.Add(new ObservableGroup("Add", itemsList)); + + readOnlyGroup.Should().ContainSingle(); + readOnlyGroup.Count.Should().Be(1); + readOnlyGroup.ElementAt(0).Key.Should().Be("Add"); + readOnlyGroup.ElementAt(0).Should().BeEquivalentTo(itemsList, o => o.WithoutStrictOrdering()); + + isCountPropertyChangedEventRaised.Should().BeTrue(); + collectionChangedEventArgs.Should().NotBeNull(); + IsAddEventValid(collectionChangedEventArgs, itemsList).Should().BeTrue(); + } + + [TestCategory("Observables")] + [TestMethod] + public void RemoveGroupInSource_ShoudRemoveGroup() + { + NotifyCollectionChangedEventArgs collectionChangedEventArgs = null; + var isCountPropertyChangedEventRaised = false; + var aItemsList = new[] { 1, 2, 3 }; + var bItemsList = new[] { 2, 4, 6 }; + var groups = new List> + { + new IntGroup("A", aItemsList), + new IntGroup("B", bItemsList), + }; + var source = new ObservableGroupedCollection(groups); + var readOnlyGroup = new ReadOnlyObservableGroupedCollection(source); + ((INotifyCollectionChanged)readOnlyGroup).CollectionChanged += (s, e) => collectionChangedEventArgs = e; + ((INotifyPropertyChanged)readOnlyGroup).PropertyChanged += (s, e) => isCountPropertyChangedEventRaised = isCountPropertyChangedEventRaised || e.PropertyName == nameof(readOnlyGroup.Count); + + source.RemoveAt(0); + + readOnlyGroup.Should().ContainSingle(); + readOnlyGroup.Count.Should().Be(1); + readOnlyGroup.ElementAt(0).Key.Should().Be("B"); + readOnlyGroup.ElementAt(0).Should().BeEquivalentTo(bItemsList, o => o.WithoutStrictOrdering()); + + isCountPropertyChangedEventRaised.Should().BeTrue(); + collectionChangedEventArgs.Should().NotBeNull(); + IsRemoveEventValid(collectionChangedEventArgs, aItemsList).Should().BeTrue(); + } + + [TestCategory("Observables")] + [TestMethod] + public void MoveGroupInSource_ShoudMoveGroup() + { + NotifyCollectionChangedEventArgs collectionChangedEventArgs = null; + var isCountPropertyChangedEventRaised = false; + var aItemsList = new[] { 1, 2, 3 }; + var bItemsList = new[] { 2, 4, 6 }; + var groups = new List> + { + new IntGroup("A", aItemsList), + new IntGroup("B", bItemsList), + }; + var source = new ObservableGroupedCollection(groups); + var readOnlyGroup = new ReadOnlyObservableGroupedCollection(source); + ((INotifyCollectionChanged)readOnlyGroup).CollectionChanged += (s, e) => collectionChangedEventArgs = e; + ((INotifyPropertyChanged)readOnlyGroup).PropertyChanged += (s, e) => isCountPropertyChangedEventRaised = isCountPropertyChangedEventRaised || e.PropertyName == nameof(readOnlyGroup.Count); + + source.Move(1, 0); + + readOnlyGroup.Should().HaveCount(2); + readOnlyGroup.Count.Should().Be(2); + readOnlyGroup.ElementAt(0).Key.Should().Be("B"); + readOnlyGroup.ElementAt(0).Should().BeEquivalentTo(bItemsList, o => o.WithoutStrictOrdering()); + readOnlyGroup.ElementAt(1).Key.Should().Be("A"); + readOnlyGroup.ElementAt(1).Should().BeEquivalentTo(aItemsList, o => o.WithoutStrictOrdering()); + + isCountPropertyChangedEventRaised.Should().BeFalse(); + collectionChangedEventArgs.Should().NotBeNull(); + IsMoveEventValid(collectionChangedEventArgs, bItemsList, 1, 0).Should().BeTrue(); + } + + [TestCategory("Observables")] + [TestMethod] + public void ClearSource_ShoudClear() + { + NotifyCollectionChangedEventArgs collectionChangedEventArgs = null; + var isCountPropertyChangedEventRaised = false; + var aItemsList = new[] { 1, 2, 3 }; + var bItemsList = new[] { 2, 4, 6 }; + var groups = new List> + { + new IntGroup("A", aItemsList), + new IntGroup("B", bItemsList), + }; + var source = new ObservableGroupedCollection(groups); + var readOnlyGroup = new ReadOnlyObservableGroupedCollection(source); + ((INotifyCollectionChanged)readOnlyGroup).CollectionChanged += (s, e) => collectionChangedEventArgs = e; + ((INotifyPropertyChanged)readOnlyGroup).PropertyChanged += (s, e) => isCountPropertyChangedEventRaised = isCountPropertyChangedEventRaised || e.PropertyName == nameof(readOnlyGroup.Count); + + source.Clear(); + + readOnlyGroup.Should().BeEmpty(); + readOnlyGroup.Count.Should().Be(0); + + isCountPropertyChangedEventRaised.Should().BeTrue(); + collectionChangedEventArgs.Should().NotBeNull(); + IsResetEventValid(collectionChangedEventArgs).Should().BeTrue(); + } + + [TestCategory("Observables")] + [TestMethod] + public void ReplaceGroupInSource_ShoudReplaceGroup() + { + NotifyCollectionChangedEventArgs collectionChangedEventArgs = null; + var isCountPropertyChangedEventRaised = false; + var aItemsList = new[] { 1, 2, 3 }; + var bItemsList = new[] { 2, 4, 6 }; + var cItemsList = new[] { 7, 8, 9 }; + var groups = new List> + { + new IntGroup("A", aItemsList), + new IntGroup("B", bItemsList), + }; + var source = new ObservableGroupedCollection(groups); + var readOnlyGroup = new ReadOnlyObservableGroupedCollection(source); + ((INotifyCollectionChanged)readOnlyGroup).CollectionChanged += (s, e) => collectionChangedEventArgs = e; + ((INotifyPropertyChanged)readOnlyGroup).PropertyChanged += (s, e) => isCountPropertyChangedEventRaised = isCountPropertyChangedEventRaised || e.PropertyName == nameof(readOnlyGroup.Count); + + source[0] = new ObservableGroup("C", cItemsList); + + readOnlyGroup.Should().HaveCount(2); + readOnlyGroup.Count.Should().Be(2); + readOnlyGroup.ElementAt(0).Key.Should().Be("C"); + readOnlyGroup.ElementAt(0).Should().BeEquivalentTo(cItemsList, o => o.WithoutStrictOrdering()); + readOnlyGroup.ElementAt(1).Key.Should().Be("B"); + readOnlyGroup.ElementAt(1).Should().BeEquivalentTo(bItemsList, o => o.WithoutStrictOrdering()); + + isCountPropertyChangedEventRaised.Should().BeFalse(); + collectionChangedEventArgs.Should().NotBeNull(); + IsReplaceEventValid(collectionChangedEventArgs, aItemsList, cItemsList).Should().BeTrue(); + } + + private static bool IsAddEventValid(NotifyCollectionChangedEventArgs args, IEnumerable expectedGroupItems) + { + var newItems = args.NewItems?.Cast>(); + return args.Action == NotifyCollectionChangedAction.Add && + args.OldItems == null && + newItems?.Count() == 1 && + Enumerable.SequenceEqual(newItems.ElementAt(0), expectedGroupItems); + } + + private static bool IsRemoveEventValid(NotifyCollectionChangedEventArgs args, IEnumerable expectedGroupItems) + { + var oldItems = args.OldItems?.Cast>(); + return args.Action == NotifyCollectionChangedAction.Remove && + args.NewItems == null && + oldItems?.Count() == 1 && + Enumerable.SequenceEqual(oldItems.ElementAt(0), expectedGroupItems); + } + + private static bool IsMoveEventValid(NotifyCollectionChangedEventArgs args, IEnumerable expectedGroupItems, int oldIndex, int newIndex) + { + var oldItems = args.OldItems?.Cast>(); + var newItems = args.NewItems?.Cast>(); + return args.Action == NotifyCollectionChangedAction.Move && + args.OldStartingIndex == oldIndex && + args.NewStartingIndex == newIndex && + oldItems?.Count() == 1 && + Enumerable.SequenceEqual(oldItems.ElementAt(0), expectedGroupItems) && + newItems?.Count() == 1 && + Enumerable.SequenceEqual(newItems.ElementAt(0), expectedGroupItems); + } + + private static bool IsReplaceEventValid(NotifyCollectionChangedEventArgs args, IEnumerable expectedRemovedItems, IEnumerable expectedAddItems) + { + var oldItems = args.OldItems?.Cast>(); + var newItems = args.NewItems?.Cast>(); + return args.Action == NotifyCollectionChangedAction.Replace && + oldItems?.Count() == 1 && + Enumerable.SequenceEqual(oldItems.ElementAt(0), expectedRemovedItems) && + newItems?.Count() == 1 && + Enumerable.SequenceEqual(newItems.ElementAt(0), expectedAddItems); + } + + private static bool IsResetEventValid(NotifyCollectionChangedEventArgs args) => args.Action == NotifyCollectionChangedAction.Reset && args.NewItems == null && args.OldItems == null; + } +} diff --git a/UnitTests/UnitTests.csproj b/UnitTests/UnitTests.csproj index b4208dc4fef..8e11eafd391 100644 --- a/UnitTests/UnitTests.csproj +++ b/UnitTests/UnitTests.csproj @@ -113,6 +113,9 @@ + + 5.10.2 + 6.2.9 @@ -168,6 +171,11 @@ + + + + + From 3ac2a8c67b31c9308c2c2a5c9448fd3493e6bbbe Mon Sep 17 00:00:00 2001 From: Ze Groumf Date: Wed, 25 Mar 2020 00:08:51 +0100 Subject: [PATCH 03/13] First drop of sample page --- .../Microsoft.Toolkit.Uwp.SampleApp.csproj | 8 ++++ .../ObservableGroup/ObservableGroup.bind | 46 +++++++++++++++++++ .../ObservableGroup/ObservableGroupPage.xaml | 15 ++++++ .../ObservableGroupPage.xaml.cs | 29 ++++++++++++ .../SamplePages/samples.json | 10 ++++ 5 files changed, 108 insertions(+) create mode 100644 Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroup.bind create mode 100644 Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml create mode 100644 Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml.cs diff --git a/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj b/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj index d95e24c902f..c3dbbecb531 100644 --- a/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj +++ b/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj @@ -528,6 +528,9 @@ ImageExLazyLoadingControl.xaml + + ObservableGroupPage.xaml + OnDevicePage.xaml @@ -552,6 +555,7 @@ + @@ -942,6 +946,10 @@ Designer MSBuild:Compile + + Designer + MSBuild:Compile + MSBuild:Compile Designer diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroup.bind b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroup.bind new file mode 100644 index 00000000000..08a38a9fea5 --- /dev/null +++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroup.bind @@ -0,0 +1,46 @@ +// Creates a CollectionView that can sort and filter items. +using Microsoft.Toolkit.Uwp.UI; + +// Grab a sample type +public class Person +{ + public string Name { get; set; } +} + +// Set up the original list with a few sample items + +var oc = new ObservableCollection +{ + new Person { Name = "Staff" }, + new Person { Name = "42" }, + new Person { Name = "Swan" }, + new Person { Name = "Orchid" }, + new Person { Name = "15" }, + new Person { Name = "Flame" }, + new Person { Name = "16" }, + new Person { Name = "Arrow" }, + new Person { Name = "Tempest" }, + new Person { Name = "23" }, + new Person { Name = "Pearl" }, + new Person { Name = "Hydra" }, + new Person { Name = "Lamp Post" }, + new Person { Name = "4" }, + new Person { Name = "Looking Glass" }, + new Person { Name = "8" }, +}; + +// Set up the AdvancedCollectionView to filter and sort the original list + +var acv = new AdvancedCollectionView(oc); + +// Let's filter out the integers +int nul; +acv.Filter = x => !int.TryParse(((Person)x).Name, out nul); + +// And sort ascending by the property "Name" +acv.SortDescriptions.Add(new SortDescription("Name", SortDirection.Ascending)); + +// AdvancedCollectionView can be bound to anything that uses collections. In this case there are two ListViews, one for the original and one for the filtered-sorted list. + +LeftList.ItemsSource = oc; +RightList.ItemsSource = acv; \ No newline at end of file diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml new file mode 100644 index 00000000000..62bca0c4a8e --- /dev/null +++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml @@ -0,0 +1,15 @@ + + + + + + diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml.cs b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml.cs new file mode 100644 index 00000000000..29ce002291f --- /dev/null +++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Foundation; +using Windows.Foundation.Collections; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Controls.Primitives; +using Windows.UI.Xaml.Data; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; +using Windows.UI.Xaml.Navigation; + +namespace Microsoft.Toolkit.Uwp.SampleApp.SamplePages +{ + /// + /// The sample page for the observable group collections. + /// + public sealed partial class ObservableGroupPage : Page + { + public ObservableGroupPage() => InitializeComponent(); + } +} diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json index 22cef444cca..c69aa6bed36 100644 --- a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json +++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json @@ -810,6 +810,16 @@ "Icon": "/SamplePages/AdvancedCollectionView/AdvancedCollectionView.png", "DocumentationUrl": "https://raw.githubusercontent.com/MicrosoftDocs/WindowsCommunityToolkitDocs/master/docs/helpers/AdvancedCollectionView.md" }, + { + "Name": "ObservableGroup", + "Type": "ObservableGroupPage", + "Subcategory": "Data", + "About": "Allows you to easily create observable grouped collections.", + "CodeUrl": "https://github.com/windows-toolkit/WindowsCommunityToolkit/tree/master/Microsoft.Toolkit/ObservableGroup", + "CodeFile": "ObservableGroup.bind", + "Icon": "/Assets/Helpers.png", + "DocumentationUrl": "https://raw.githubusercontent.com/MicrosoftDocs/WindowsCommunityToolkitDocs/master/docs/helpers/ObservableGroup.md" + }, { "Name": "CameraHelper", "Type": "CameraHelperPage", From b84328811bf7037df3c4cfc5e006dc3f7a5babfb Mon Sep 17 00:00:00 2001 From: Ze Groumf Date: Thu, 26 Mar 2020 00:18:16 +0100 Subject: [PATCH 04/13] updated sample and fixed ReadOnlyGroupedCollection implementation --- .../ObservableGroup/ObservableGroupPage.xaml | 54 ++++++++++++- .../ObservableGroupPage.xaml.cs | 81 ++++++++++++++++--- .../ReadOnlyObservableGroupedCollection.cs | 57 ++++++++++++- ...eadOnlyObservableGroupedCollectionTests.cs | 13 +-- 4 files changed, 180 insertions(+), 25 deletions(-) diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml index 62bca0c4a8e..5b5228183cf 100644 --- a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml +++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml @@ -7,9 +7,57 @@ Background="{ThemeResource ApplicationPageBackgroundThemeBrush}" mc:Ignorable="d"> + + + + + + + + + + + + + + - + + + + + + + +