diff --git a/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj b/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj
index 77be6020d17..7212c223cc6 100644
--- a/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj
+++ b/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj
@@ -518,6 +518,9 @@
ImageExLazyLoadingControl.xaml
+
+ ObservableGroupPage.xaml
+
OnDevicePage.xaml
@@ -542,6 +545,7 @@
+
@@ -939,6 +943,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..d4344fd4d6d
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroup.bind
@@ -0,0 +1,40 @@
+// Grab a sample type
+public class Person
+{
+ public string Name { get; set; }
+}
+
+// Set up the original list with a few sample items
+var contacts = new[]
+{
+ new Person { Name = "Staff" },
+ new Person { Name = "Swan" },
+ new Person { Name = "Orchid" },
+ new Person { Name = "Flame" },
+ new Person { Name = "Arrow" },
+ new Person { Name = "Tempest" },
+ new Person { Name = "Pearl" },
+ new Person { Name = "Hydra" },
+ new Person { Name = "Lamp Post" },
+ new Person { Name = "Looking Glass" },
+};
+
+// Group the contacts by first letter
+var grouped = contacts.GroupBy(GetGroupName).OrderBy(g => g.Key);
+
+// Create an observable grouped collection
+var contactsSource = new ObservableGroupedCollection(grouped);
+
+// Create a read-only observable grouped collection
+// Note: This step is optional. It is usually used in view models to expose the collection as read-only and prevent the view from altering it.
+var readonlyContacts = new ReadOnlyObservableGroupedCollection(contactsSource);
+
+// Set up the CollectionViewSource
+var cvs = new CollectionViewSource
+{
+ IsSourceGrouped = True,
+ Source = readonlyContacts,
+};
+
+// Bind the CollectionViewSource to anything that supports grouped collections.
+ContactsList.ItemsSource = cvs.View;
\ 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..d57785e8ac1
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..ae9c7965119
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/ObservableGroup/ObservableGroupPage.xaml.cs
@@ -0,0 +1,99 @@
+// 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.Linq;
+using Microsoft.Toolkit.Collections;
+using Windows.System;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+
+namespace Microsoft.Toolkit.Uwp.SampleApp.SamplePages
+{
+ ///
+ /// The sample page for the observable group collections.
+ ///
+ public sealed partial class ObservableGroupPage : Page
+ {
+ private readonly ObservableGroupedCollection _contactsSource;
+
+ public ObservableGroupPage()
+ {
+ var contacts = new[]
+ {
+ new Person { Name = "Staff" },
+ new Person { Name = "Swan" },
+ new Person { Name = "Orchid" },
+ new Person { Name = "Flame" },
+ new Person { Name = "Arrow" },
+ new Person { Name = "Tempest" },
+ new Person { Name = "Pearl" },
+ new Person { Name = "Hydra" },
+ new Person { Name = "Lamp Post" },
+ new Person { Name = "Looking Glass" },
+ };
+ var grouped = contacts.GroupBy(GetGroupName).OrderBy(g => g.Key);
+ _contactsSource = new ObservableGroupedCollection(grouped);
+ Contacts = new ReadOnlyObservableGroupedCollection(_contactsSource);
+
+ InitializeComponent();
+ }
+
+ public ReadOnlyObservableGroupedCollection Contacts { get; }
+
+ private static string GetGroupName(Person person) => person.Name.First().ToString().ToUpper();
+
+ private void OnContactsListViewSelectionChanged(object sender, SelectionChangedEventArgs e) => RemoveContact.IsEnabled = ContactsListView.SelectedItem is Person;
+
+ private void OnRemoveButtonClick(object sender, RoutedEventArgs e)
+ {
+ var selectedContact = (Person)ContactsListView.SelectedItem;
+ var selectedGroupName = GetGroupName(selectedContact);
+ var selectedGroup = _contactsSource.FirstOrDefault(group => group.Key == selectedGroupName);
+ if (selectedGroup != null)
+ {
+ selectedGroup.Remove(selectedContact);
+ if (!selectedGroup.Any())
+ {
+ // The group is empty. We can remove it.
+ _contactsSource.Remove(selectedGroup);
+ }
+ }
+ }
+
+ private bool CanAddContact() => !string.IsNullOrEmpty(NewContact.Text.Trim());
+
+ private void AddNewContact()
+ {
+ var newContact = new Person
+ {
+ Name = NewContact.Text.Trim(),
+ };
+
+ var groupName = GetGroupName(newContact);
+ var targetGroup = _contactsSource.FirstOrDefault(group => group.Key == groupName);
+ if (targetGroup is null)
+ {
+ _contactsSource.Add(new ObservableGroup(groupName, new[] { newContact }));
+ }
+ else
+ {
+ targetGroup.Add(newContact);
+ }
+
+ NewContact.Text = string.Empty;
+ }
+
+ private void OnAddContactClick(object sender, RoutedEventArgs e) => AddNewContact();
+
+ private void OnNewContactTextChanged(object sender, TextChangedEventArgs e) => AddContact.IsEnabled = CanAddContact();
+
+ private void OnNewContactKeyDown(object sender, Windows.UI.Xaml.Input.KeyRoutedEventArgs e)
+ {
+ if (e.Key == VirtualKey.Enter && CanAddContact())
+ {
+ AddNewContact();
+ }
+ }
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json
index e7f77241fb6..de9b02ca69e 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",
diff --git a/Microsoft.Toolkit/Collections/IReadOnlyObservableGroup.cs b/Microsoft.Toolkit/Collections/IReadOnlyObservableGroup.cs
new file mode 100644
index 00000000000..89c302345de
--- /dev/null
+++ b/Microsoft.Toolkit/Collections/IReadOnlyObservableGroup.cs
@@ -0,0 +1,27 @@
+// 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.ComponentModel;
+
+namespace Microsoft.Toolkit.Collections
+{
+ ///
+ /// An interface for a grouped collection of items.
+ /// It allows us to use x:Bind with and by providing
+ /// a non-generic type that we can declare using x:DataType.
+ ///
+ public interface IReadOnlyObservableGroup : INotifyPropertyChanged
+ {
+ ///
+ /// Gets the key for the current collection, as an .
+ /// It is immutable.
+ ///
+ object Key { get; }
+
+ ///
+ /// Gets the number of items currently in the grouped collection.
+ ///
+ int Count { get; }
+ }
+}
diff --git a/Microsoft.Toolkit/Collections/ObservableGroup.cs b/Microsoft.Toolkit/Collections/ObservableGroup.cs
new file mode 100644
index 00000000000..c81218a94e8
--- /dev/null
+++ b/Microsoft.Toolkit/Collections/ObservableGroup.cs
@@ -0,0 +1,57 @@
+// 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.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, IReadOnlyObservableGroup
+ {
+ ///
+ /// 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;
+ }
+
+ ///
+ /// Gets the key of the group.
+ ///
+ public TKey Key { get; }
+
+ ///
+ object IReadOnlyObservableGroup.Key => Key;
+ }
+}
diff --git a/Microsoft.Toolkit/Collections/ObservableGroupedCollection.cs b/Microsoft.Toolkit/Collections/ObservableGroupedCollection.cs
new file mode 100644
index 00000000000..7c8be2f0c74
--- /dev/null
+++ b/Microsoft.Toolkit/Collections/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.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)))
+ {
+ }
+ }
+}
diff --git a/Microsoft.Toolkit/Collections/ReadOnlyObservableGroup.cs b/Microsoft.Toolkit/Collections/ReadOnlyObservableGroup.cs
new file mode 100644
index 00000000000..b060db390d0
--- /dev/null
+++ b/Microsoft.Toolkit/Collections/ReadOnlyObservableGroup.cs
@@ -0,0 +1,56 @@
+// 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.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, IReadOnlyObservableGroup
+ {
+ ///
+ /// 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;
+ }
+
+ ///
+ /// 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, IEnumerable collection)
+ : base(new ObservableCollection(collection))
+ {
+ Key = key;
+ }
+
+ ///
+ public TKey Key { get; }
+
+ ///
+ object IReadOnlyObservableGroup.Key => Key;
+ }
+}
diff --git a/Microsoft.Toolkit/Collections/ReadOnlyObservableGroupedCollection.cs b/Microsoft.Toolkit/Collections/ReadOnlyObservableGroupedCollection.cs
new file mode 100644
index 00000000000..9ff14698586
--- /dev/null
+++ b/Microsoft.Toolkit/Collections/ReadOnlyObservableGroupedCollection.cs
@@ -0,0 +1,85 @@
+// 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.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.Diagnostics;
+using System.Linq;
+
+namespace Microsoft.Toolkit.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 : ReadOnlyObservableCollection>
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The source collection to wrap.
+ public ReadOnlyObservableGroupedCollection(ObservableGroupedCollection collection)
+ : this(collection.Select(g => new ReadOnlyObservableGroup(g)))
+ {
+ ((INotifyCollectionChanged)collection).CollectionChanged += this.OnSourceCollectionChanged;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The initial data to add in the grouped collection.
+ public ReadOnlyObservableGroupedCollection(IEnumerable> collection)
+ : base(new ObservableCollection>(collection))
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The initial data to add in the grouped collection.
+ public ReadOnlyObservableGroupedCollection(IEnumerable> collection)
+ : this(collection.Select(g => new ReadOnlyObservableGroup(g.Key, g)))
+ {
+ }
+
+ private void OnSourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ // Even if the NotifyCollectionChangedEventArgs allows multiple items, the actual implementation is only
+ // reporting the changes one by one. We consider only this case for now.
+ if (e.OldItems?.Count > 1 || e.NewItems?.Count > 1)
+ {
+ Debug.Fail("OldItems and NewItems should contain at most 1 item");
+ throw new NotSupportedException();
+ }
+
+ var newItem = e.NewItems?.Cast>()?.FirstOrDefault();
+
+ switch (e.Action)
+ {
+ case NotifyCollectionChangedAction.Add:
+ Items.Insert(e.NewStartingIndex, new ReadOnlyObservableGroup(newItem));
+ break;
+ case NotifyCollectionChangedAction.Move:
+ // Our inner Items list is our own ObservableCollection> so we can safely cast Items to its concrete type here.
+ ((ObservableCollection>)Items).Move(e.OldStartingIndex, e.NewStartingIndex);
+ break;
+ case NotifyCollectionChangedAction.Remove:
+ Items.RemoveAt(e.OldStartingIndex);
+ break;
+ case NotifyCollectionChangedAction.Replace:
+ Items[e.OldStartingIndex] = new ReadOnlyObservableGroup(newItem);
+ break;
+ case NotifyCollectionChangedAction.Reset:
+ Items.Clear();
+ break;
+ default:
+ Debug.Fail("unsupported value");
+ break;
+ }
+ }
+ }
+}
diff --git a/UnitTests/Collections/IntGroup.cs b/UnitTests/Collections/IntGroup.cs
new file mode 100644
index 00000000000..2cdd9121b22
--- /dev/null
+++ b/UnitTests/Collections/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.Collections
+{
+ public class IntGroup : List, IGrouping
+ {
+ public IntGroup(string key, IEnumerable collection)
+ : base(collection)
+ {
+ Key = key;
+ }
+
+ public string Key { get; }
+ }
+}
diff --git a/UnitTests/Collections/ObservableGroupTests.cs b/UnitTests/Collections/ObservableGroupTests.cs
new file mode 100644
index 00000000000..92a08f5a68b
--- /dev/null
+++ b/UnitTests/Collections/ObservableGroupTests.cs
@@ -0,0 +1,125 @@
+// 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.Linq;
+using FluentAssertions;
+using Microsoft.Toolkit.Collections;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.Collections.Specialized;
+
+namespace UnitTests.Collections
+{
+ [TestClass]
+ public class ObservableGroupTests
+ {
+ [TestCategory("Collections")]
+ [TestMethod]
+ public void Ctor_ShouldHaveExpectedState()
+ {
+ var group = new ObservableGroup("key");
+
+ group.Key.Should().Be("key");
+ group.Should().BeEmpty();
+ }
+
+ [TestCategory("Collections")]
+ [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("Collections")]
+ [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("Collections")]
+ [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("Collections")]
+ [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("Collections")]
+ [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("Collections")]
+ [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();
+ }
+
+ [TestCategory("Collections")]
+ [DataTestMethod]
+ [DataRow(0)]
+ [DataRow(3)]
+ public void IReadOnlyObservableGroup_ShouldReturnExpectedValues(int count)
+ {
+ var group = new ObservableGroup("key", Enumerable.Range(0, count));
+ var iReadOnlyObservableGroup = (IReadOnlyObservableGroup)group;
+
+ iReadOnlyObservableGroup.Key.Should().Be("key");
+ iReadOnlyObservableGroup.Count.Should().Be(count);
+ }
+ }
+}
diff --git a/UnitTests/Collections/ObservableGroupedCollectionTests.cs b/UnitTests/Collections/ObservableGroupedCollectionTests.cs
new file mode 100644
index 00000000000..49b0634e982
--- /dev/null
+++ b/UnitTests/Collections/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.Collections;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace UnitTests.Collections
+{
+ [TestClass]
+ public class ObservableGroupedCollectionTests
+ {
+ [TestCategory("Collections")]
+ [TestMethod]
+ public void Ctor_ShouldHaveExpectedValues()
+ {
+ var groupCollection = new ObservableGroupedCollection();
+
+ groupCollection.Should().BeEmpty();
+ }
+
+ [TestCategory("Collections")]
+ [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/Collections/ReadOnlyObservableGroupTests.cs b/UnitTests/Collections/ReadOnlyObservableGroupTests.cs
new file mode 100644
index 00000000000..35885107620
--- /dev/null
+++ b/UnitTests/Collections/ReadOnlyObservableGroupTests.cs
@@ -0,0 +1,133 @@
+// 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.Collections.Specialized;
+using System.Linq;
+using FluentAssertions;
+using Microsoft.Toolkit.Collections;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace UnitTests.Collections
+{
+ [TestClass]
+ public class ReadOnlyObservableGroupTests
+ {
+ [TestCategory("Collections")]
+ [TestMethod]
+ public void Ctor_WithKeyAndOBservableCollection_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("Collections")]
+ [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("Collections")]
+ [TestMethod]
+ public void Ctor_WithKeyAndCollection_ShouldHaveExpectedInitialState()
+ {
+ var source = 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("Collections")]
+ [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("Collections")]
+ [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("Collections")]
+ [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("Collections")]
+ [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();
+ }
+
+ [TestCategory("Collections")]
+ [DataTestMethod]
+ [DataRow(0)]
+ [DataRow(3)]
+ public void IReadOnlyObservableGroup_ShouldReturnExpectedValues(int count)
+ {
+ var sourceGroup = new ObservableGroup("key", Enumerable.Range(0, count));
+ var group = new ReadOnlyObservableGroup(sourceGroup);
+ var iReadOnlyObservableGroup = (IReadOnlyObservableGroup)group;
+
+ iReadOnlyObservableGroup.Key.Should().Be("key");
+ iReadOnlyObservableGroup.Count.Should().Be(count);
+ }
+ }
+}
diff --git a/UnitTests/Collections/ReadOnlyObservableGroupedCollectionTests.cs b/UnitTests/Collections/ReadOnlyObservableGroupedCollectionTests.cs
new file mode 100644
index 00000000000..1e69d45246a
--- /dev/null
+++ b/UnitTests/Collections/ReadOnlyObservableGroupedCollectionTests.cs
@@ -0,0 +1,472 @@
+// 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.Collections;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Linq;
+
+namespace UnitTests.Collections
+{
+ [TestClass]
+ public class ReadOnlyObservableGroupedCollectionTests
+ {
+ [TestCategory("Collections")]
+ [TestMethod]
+ public void Ctor_WithEmptySource_ShoudInitializeObject()
+ {
+ var source = new ObservableGroupedCollection();
+ var readOnlyGroup = new ReadOnlyObservableGroupedCollection(source);
+
+ readOnlyGroup.Should().BeEmpty();
+ readOnlyGroup.Count.Should().Be(0);
+ }
+
+ [TestCategory("Collections")]
+ [TestMethod]
+ public void Ctor_WithObservableGroupedCollection_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("Collections")]
+ [TestMethod]
+ public void Ctor_WithListOfIGroupingSource_ShoudInitializeObject()
+ {
+ var source = new List>
+ {
+ new IntGroup("A", new[] { 1, 3, 5 }),
+ new IntGroup("B", new[] { 2, 4, 6 }),
+ };
+ 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("Collections")]
+ [TestMethod]
+ public void Ctor_WithListOfReadOnlyObservableGroupSource_ShoudInitializeObject()
+ {
+ var source = new List>
+ {
+ new ReadOnlyObservableGroup("A", new[] { 1, 3, 5 }),
+ new ReadOnlyObservableGroup("B", new[] { 2, 4, 6 }),
+ };
+ 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("Collections")]
+ [TestMethod]
+ public void IListImplementation_Properties_ShoudReturnExpectedValues()
+ {
+ 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);
+ var list = (IList)readOnlyGroup;
+
+ list.Count.Should().Be(2);
+ var group0 = (ReadOnlyObservableGroup)list[0];
+ group0.Key.Should().Be("A");
+ group0.Should().BeEquivalentTo(new[] { 1, 3, 5 }, o => o.WithoutStrictOrdering());
+ var group1 = (ReadOnlyObservableGroup)list[1];
+ group1.Key.Should().Be("B");
+ group1.Should().BeEquivalentTo(new[] { 2, 4, 6 }, o => o.WithoutStrictOrdering());
+
+ list.SyncRoot.Should().NotBeNull();
+ list.IsFixedSize.Should().BeTrue();
+ list.IsReadOnly.Should().BeTrue();
+ list.IsSynchronized.Should().BeFalse();
+ }
+
+ [TestCategory("Collections")]
+ [TestMethod]
+ public void IListImplementation_MutableMethods_ShoudThrow()
+ {
+ 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);
+ var list = (IList)readOnlyGroup;
+
+ var testGroup = new ReadOnlyObservableGroup("test", new ObservableCollection());
+ Action add = () => list.Add(testGroup);
+ add.Should().Throw();
+
+ Action clear = () => list.Clear();
+ clear.Should().Throw();
+
+ Action insert = () => list.Insert(2, testGroup);
+ insert.Should().Throw();
+
+ Action remove = () => list.Remove(testGroup);
+ remove.Should().Throw();
+
+ Action removeAt = () => list.RemoveAt(2);
+ removeAt.Should().Throw();
+
+ Action set = () => list[2] = testGroup;
+ set.Should().Throw();
+
+ var array = new object[5];
+ Action copyTo = () => list.CopyTo(array, 0);
+ copyTo.Should().NotThrow();
+ }
+
+ [TestCategory("Collections")]
+ [DataTestMethod]
+ [DataRow(-1)]
+ [DataRow(0)]
+ [DataRow(1)]
+ [DataRow(2)]
+ public void IListImplementation_IndexOf_ShoudReturnExpectedValue(int groupIndex)
+ {
+ var groups = new List>
+ {
+ new IntGroup("A", new[] { 1, 3, 5 }),
+ new IntGroup("B", new[] { 2, 4, 6 }),
+ new IntGroup("C", new[] { 7, 8, 9 }),
+ };
+ var source = new ObservableGroupedCollection(groups);
+ var readOnlyGroup = new ReadOnlyObservableGroupedCollection(source);
+ var list = (IList)readOnlyGroup;
+
+ var groupToSearch = groupIndex >= 0 ? list[groupIndex] : null;
+
+ var index = list.IndexOf(groupToSearch);
+
+ index.Should().Be(groupIndex);
+ }
+
+ [TestCategory("Collections")]
+ [DataTestMethod]
+ [DataRow(-1, false)]
+ [DataRow(0, true)]
+ [DataRow(1, true)]
+ public void IListImplementation_Contains_ShoudReturnExpectedValue(int groupIndex, bool expectedResult)
+ {
+ 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);
+ var list = (IList)readOnlyGroup;
+
+ var groupToSearch = groupIndex >= 0 ? list[groupIndex] : null;
+
+ var result = list.Contains(groupToSearch);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCategory("Collections")]
+ [DataTestMethod]
+ [DataRow(0, 0)]
+ [DataRow(3, 3)]
+ public void AddGroupInSource_ShouldAddGroup(int sourceInitialItemsCount, int expectedInsertionIndex)
+ {
+ NotifyCollectionChangedEventArgs collectionChangedEventArgs = null;
+ var collectionChangedEventsCount = 0;
+ var isCountPropertyChangedEventRaised = false;
+ var itemsList = new[] { 1, 2, 3 };
+ var source = new ObservableGroupedCollection();
+ for (var i = 0; i < sourceInitialItemsCount; i++)
+ {
+ source.Add(new ObservableGroup($"group {i}", Enumerable.Empty()));
+ }
+
+ var readOnlyGroup = new ReadOnlyObservableGroupedCollection(source);
+ ((INotifyCollectionChanged)readOnlyGroup).CollectionChanged += (s, e) =>
+ {
+ collectionChangedEventArgs = e;
+ collectionChangedEventsCount++;
+ };
+ ((INotifyPropertyChanged)readOnlyGroup).PropertyChanged += (s, e) => isCountPropertyChangedEventRaised = isCountPropertyChangedEventRaised || e.PropertyName == nameof(readOnlyGroup.Count);
+
+ source.Add(new ObservableGroup("Add", itemsList));
+
+ var expectedReadOnlyGroupCount = sourceInitialItemsCount + 1;
+ readOnlyGroup.Should().HaveCount(expectedReadOnlyGroupCount);
+ readOnlyGroup.Count.Should().Be(expectedReadOnlyGroupCount);
+ readOnlyGroup.Last().Key.Should().Be("Add");
+ readOnlyGroup.Last().Should().BeEquivalentTo(itemsList, o => o.WithoutStrictOrdering());
+
+ isCountPropertyChangedEventRaised.Should().BeTrue();
+ collectionChangedEventArgs.Should().NotBeNull();
+ collectionChangedEventsCount.Should().Be(1);
+ IsAddEventValid(collectionChangedEventArgs, itemsList, expectedInsertionIndex).Should().BeTrue();
+ }
+
+ [TestCategory("Collections")]
+ [DataTestMethod]
+ [DataRow(0)]
+ [DataRow(1)]
+ [DataRow(2)]
+ public void InsertGroupInSource_ShouldAddGroup(int insertionIndex)
+ {
+ NotifyCollectionChangedEventArgs collectionChangedEventArgs = null;
+ var collectionChangedEventsCount = 0;
+ var isCountPropertyChangedEventRaised = false;
+ var itemsList = new[] { 1, 2, 3 };
+ var source = new ObservableGroupedCollection
+ {
+ new ObservableGroup("Group0", new[] { 10, 20, 30 }),
+ new ObservableGroup("Group1", new[] { 40, 50, 60 })
+ };
+ var readOnlyGroup = new ReadOnlyObservableGroupedCollection(source);
+ ((INotifyCollectionChanged)readOnlyGroup).CollectionChanged += (s, e) =>
+ {
+ collectionChangedEventArgs = e;
+ collectionChangedEventsCount++;
+ };
+ ((INotifyPropertyChanged)readOnlyGroup).PropertyChanged += (s, e) => isCountPropertyChangedEventRaised = isCountPropertyChangedEventRaised || e.PropertyName == nameof(readOnlyGroup.Count);
+
+ source.Insert(insertionIndex, new ObservableGroup("Add", itemsList));
+
+ readOnlyGroup.Should().HaveCount(3);
+ readOnlyGroup.Count.Should().Be(3);
+ readOnlyGroup.ElementAt(insertionIndex).Key.Should().Be("Add");
+ readOnlyGroup.ElementAt(insertionIndex).Should().BeEquivalentTo(itemsList, o => o.WithoutStrictOrdering());
+
+ isCountPropertyChangedEventRaised.Should().BeTrue();
+ collectionChangedEventArgs.Should().NotBeNull();
+ collectionChangedEventsCount.Should().Be(1);
+ IsAddEventValid(collectionChangedEventArgs, itemsList, addIndex: insertionIndex).Should().BeTrue();
+ }
+
+ [TestCategory("Collections")]
+ [TestMethod]
+ public void RemoveGroupInSource_ShoudRemoveGroup()
+ {
+ NotifyCollectionChangedEventArgs collectionChangedEventArgs = null;
+ var collectionChangedEventsCount = 0;
+ 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;
+ collectionChangedEventsCount++;
+ };
+ ((INotifyPropertyChanged)readOnlyGroup).PropertyChanged += (s, e) => isCountPropertyChangedEventRaised = isCountPropertyChangedEventRaised || e.PropertyName == nameof(readOnlyGroup.Count);
+
+ source.RemoveAt(1);
+
+ readOnlyGroup.Should().ContainSingle();
+ readOnlyGroup.Count.Should().Be(1);
+ readOnlyGroup.ElementAt(0).Key.Should().Be("A");
+ readOnlyGroup.ElementAt(0).Should().BeEquivalentTo(aItemsList, o => o.WithoutStrictOrdering());
+
+ isCountPropertyChangedEventRaised.Should().BeTrue();
+ collectionChangedEventArgs.Should().NotBeNull();
+ collectionChangedEventsCount.Should().Be(1);
+ IsRemoveEventValid(collectionChangedEventArgs, bItemsList, 1).Should().BeTrue();
+ }
+
+ [TestCategory("Collections")]
+ [DataTestMethod]
+ [DataRow(1, 0)]
+ [DataRow(0, 1)]
+ public void MoveGroupInSource_ShoudMoveGroup(int oldIndex, int newIndex)
+ {
+ NotifyCollectionChangedEventArgs collectionChangedEventArgs = null;
+ var collectionChangedEventsCount = 0;
+ 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;
+ collectionChangedEventsCount++;
+ };
+ ((INotifyPropertyChanged)readOnlyGroup).PropertyChanged += (s, e) => isCountPropertyChangedEventRaised = isCountPropertyChangedEventRaised || e.PropertyName == nameof(readOnlyGroup.Count);
+
+ source.Move(oldIndex, newIndex);
+
+ 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();
+ collectionChangedEventsCount.Should().Be(1);
+ IsMoveEventValid(collectionChangedEventArgs, groups[oldIndex], oldIndex, newIndex).Should().BeTrue();
+ }
+
+ [TestCategory("Collections")]
+ [TestMethod]
+ public void ClearSource_ShoudClear()
+ {
+ NotifyCollectionChangedEventArgs collectionChangedEventArgs = null;
+ var collectionChangedEventsCount = 0;
+ 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;
+ collectionChangedEventsCount++;
+ };
+ ((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();
+ collectionChangedEventsCount.Should().Be(1);
+ IsResetEventValid(collectionChangedEventArgs).Should().BeTrue();
+ }
+
+ [TestCategory("Collections")]
+ [TestMethod]
+ public void ReplaceGroupInSource_ShoudReplaceGroup()
+ {
+ NotifyCollectionChangedEventArgs collectionChangedEventArgs = null;
+ var collectionChangedEventsCount = 0;
+ 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;
+ collectionChangedEventsCount++;
+ };
+ ((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();
+ collectionChangedEventsCount.Should().Be(1);
+ IsReplaceEventValid(collectionChangedEventArgs, aItemsList, cItemsList).Should().BeTrue();
+ }
+
+ private static bool IsAddEventValid(NotifyCollectionChangedEventArgs args, IEnumerable expectedGroupItems, int addIndex)
+ {
+ var newItems = args.NewItems?.Cast>();
+ return args.Action == NotifyCollectionChangedAction.Add &&
+ args.NewStartingIndex == addIndex &&
+ args.OldItems == null &&
+ newItems?.Count() == 1 &&
+ Enumerable.SequenceEqual(newItems.ElementAt(0), expectedGroupItems);
+ }
+
+ private static bool IsRemoveEventValid(NotifyCollectionChangedEventArgs args, IEnumerable expectedGroupItems, int oldIndex)
+ {
+ var oldItems = args.OldItems?.Cast>();
+ return args.Action == NotifyCollectionChangedAction.Remove &&
+ args.NewItems == null &&
+ args.OldStartingIndex == oldIndex &&
+ 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 45aec0e7e7c..ce3edb2b442 100644
--- a/UnitTests/UnitTests.csproj
+++ b/UnitTests/UnitTests.csproj
@@ -103,6 +103,9 @@
+
+ 5.10.2
+
6.2.9
@@ -164,6 +167,11 @@
+
+
+
+
+