diff --git a/Maui.DataGrid.Sample/Tests/BindablePropertyExtensionsTest.cs b/Maui.DataGrid.Sample/Tests/BindablePropertyExtensionsTest.cs new file mode 100644 index 0000000..b5d1a80 --- /dev/null +++ b/Maui.DataGrid.Sample/Tests/BindablePropertyExtensionsTest.cs @@ -0,0 +1,47 @@ +namespace Maui.DataGrid.Sample.Tests; + +using Maui.DataGrid.Extensions; +using Xunit; + +public class BindablePropertyExtensionsTest +{ + private static readonly BindableProperty TestProperty = + BindablePropertyExtensions.Create("default"); + + private static readonly BindableProperty NumericProperty = + BindablePropertyExtensions.Create(42); + + [Fact] + public void Create_SetsDefaultValue() + { + var bindable = new TestBindable(); + + Assert.Equal("default", bindable.GetValue(TestProperty)); + } + + [Fact] + public void Create_TrimsPropertySuffix() + { + Assert.Equal("Test", TestProperty.PropertyName); + } + + [Fact] + public void Create_NumericDefaultValue() + { + var bindable = new TestBindable(); + + Assert.Equal(42, bindable.GetValue(NumericProperty)); + } + + [Fact] + public void Create_SetAndGetValue() + { + var bindable = new TestBindable(); + + bindable.SetValue(TestProperty, "updated"); + + Assert.Equal("updated", bindable.GetValue(TestProperty)); + } + + private class TestBindable : BindableObject; +} diff --git a/Maui.DataGrid.Sample/Tests/ConvertersTest.cs b/Maui.DataGrid.Sample/Tests/ConvertersTest.cs new file mode 100644 index 0000000..c91e53b --- /dev/null +++ b/Maui.DataGrid.Sample/Tests/ConvertersTest.cs @@ -0,0 +1,162 @@ +namespace Maui.DataGrid.Sample.Tests; + +using System.Globalization; +using Maui.DataGrid.Converters; +using Xunit; + +public class ConvertersTest +{ + private readonly BorderThicknessToCellPaddingConverter _borderConverter = new(); + private readonly SortDataTypeConverter _sortDataConverter = new(); + + [Fact] + public void BorderConverter_ThicknessIsDividedByTwo() + { + var thickness = new Thickness(4, 6, 8, 10); + + var result = (Thickness)_borderConverter.Convert(thickness, null, null, null); + + Assert.Equal(new Thickness(2, 3, 4, 5), result); + } + + [Fact] + public void BorderConverter_UniformThickness() + { + var thickness = new Thickness(2); + + var result = (Thickness)_borderConverter.Convert(thickness, null, null, null); + + Assert.Equal(new Thickness(1), result); + } + + [Fact] + public void BorderConverter_ZeroThickness() + { + var thickness = new Thickness(0); + + var result = (Thickness)_borderConverter.Convert(thickness, null, null, null); + + Assert.Equal(new Thickness(0), result); + } + + [Fact] + public void BorderConverter_NonThicknessReturnsZero() + { + var result = (Thickness)_borderConverter.Convert("invalid", null, null, null); + + Assert.Equal(new Thickness(0), result); + } + + [Fact] + public void BorderConverter_NullReturnsZero() + { + var result = (Thickness)_borderConverter.Convert(null, null, null, null); + + Assert.Equal(new Thickness(0), result); + } + + [Fact] + public void BorderConverter_ConvertBackThrows() + { + Assert.Throws(() => + _borderConverter.ConvertBack(null, null, null, null)); + } + + [Fact] + public void SortDataConverter_NullReturnsNull() + { + var result = _sortDataConverter.ConvertFrom(null, null, null!); + + Assert.Null(result); + } + + [Fact] + public void SortDataConverter_PositiveIntReturnsAscendant() + { + var result = (SortData)_sortDataConverter.ConvertFrom(null, null, 3)!; + + Assert.Equal(3, result.Index); + Assert.Equal(SortingOrder.Ascendant, result.Order); + } + + [Fact] + public void SortDataConverter_NegativeIntReturnsDescendant() + { + var result = (SortData)_sortDataConverter.ConvertFrom(null, null, -2)!; + + Assert.Equal(2, result.Index); + Assert.Equal(SortingOrder.Descendant, result.Order); + } + + [Fact] + public void SortDataConverter_StringParsesToSortData() + { + var result = (SortData)_sortDataConverter.ConvertFrom(null, CultureInfo.InvariantCulture, "4")!; + + Assert.Equal(4, result.Index); + Assert.Equal(SortingOrder.Ascendant, result.Order); + } + + [Fact] + public void SortDataConverter_NegativeStringParsesToDescendant() + { + var result = (SortData)_sortDataConverter.ConvertFrom(null, CultureInfo.InvariantCulture, "-1")!; + + Assert.Equal(1, result.Index); + Assert.Equal(SortingOrder.Descendant, result.Order); + } + + [Fact] + public void SortDataConverter_ZeroIntReturnsAscendant() + { + var result = (SortData)_sortDataConverter.ConvertFrom(null, null, 0)!; + + Assert.Equal(0, result.Index); + Assert.Equal(SortingOrder.Ascendant, result.Order); + } + + [Fact] + public void SortDataConverter_DescStringFormatParsesToDescendant() + { + var result = (SortData)_sortDataConverter.ConvertFrom(null, CultureInfo.InvariantCulture, "3 DESC")!; + + Assert.Equal(3, result.Index); + Assert.Equal(SortingOrder.Descendant, result.Order); + } + + [Fact] + public void SortDataConverter_AscStringFormatParsesToAscendant() + { + var result = (SortData)_sortDataConverter.ConvertFrom(null, CultureInfo.InvariantCulture, "2 ASC")!; + + Assert.Equal(2, result.Index); + Assert.Equal(SortingOrder.Ascendant, result.Order); + } + + [Fact] + public void SortDataConverter_ZeroDescStringFormatParsesToDescendant() + { + var result = (SortData)_sortDataConverter.ConvertFrom(null, CultureInfo.InvariantCulture, "0 DESC")!; + + Assert.Equal(0, result.Index); + Assert.Equal(SortingOrder.Descendant, result.Order); + } + + [Fact] + public void SortDataConverter_DescCaseInsensitive() + { + var result = (SortData)_sortDataConverter.ConvertFrom(null, CultureInfo.InvariantCulture, "1 desc")!; + + Assert.Equal(1, result.Index); + Assert.Equal(SortingOrder.Descendant, result.Order); + } + + [Fact] + public void SortDataConverter_ZeroStringParsesToAscendant() + { + var result = (SortData)_sortDataConverter.ConvertFrom(null, CultureInfo.InvariantCulture, "0")!; + + Assert.Equal(0, result.Index); + Assert.Equal(SortingOrder.Ascendant, result.Order); + } +} diff --git a/Maui.DataGrid.Sample/Tests/DataGridColumnTest.cs b/Maui.DataGrid.Sample/Tests/DataGridColumnTest.cs new file mode 100644 index 0000000..cfd768e --- /dev/null +++ b/Maui.DataGrid.Sample/Tests/DataGridColumnTest.cs @@ -0,0 +1,99 @@ +namespace Maui.DataGrid.Sample.Tests; + +using Maui.DataGrid.Sample.Tests.TestUtils; +using Xunit; + +public class DataGridColumnTest +{ + [Fact] + public void DefaultPropertyValues() + { + var column = new DataGridColumn(); + + Assert.Equal(string.Empty, column.Title); + Assert.Equal(GridLength.Star, column.Width); + Assert.True(column.IsVisible); + Assert.True(column.SortingEnabled); + Assert.True(column.FilteringEnabled); + Assert.Equal(LineBreakMode.WordWrap, column.LineBreakMode); + Assert.Equal(LayoutOptions.Center, column.HorizontalContentAlignment); + Assert.Equal(LayoutOptions.Center, column.VerticalContentAlignment); + Assert.Null(column.PropertyName); + Assert.Null(column.StringFormat); + Assert.Null(column.CellTemplate); + } + + [Fact] + public void IsSortableReturnsTrueForComparableType() + { + var column = new DataGridColumn { PropertyName = "Value" }; + + var teams = new List { new() { Value = 1 } }; + var dataGrid = new DataGrid + { + ItemsSource = teams, + Columns = [column] + }; + + column.DataGrid = dataGrid; + column.InitializeDataType(); + + Assert.True(column.IsSortable()); + } + + [Fact] + public void IsSortableReturnsFalseForNonComparableType() + { + var column = new DataGridColumn { PropertyName = "NonComparable" }; + + var items = new List { new() { NonComparable = new object() } }; + var dataGrid = new DataGrid + { + ItemsSource = items, + Columns = [column] + }; + + column.DataGrid = dataGrid; + column.InitializeDataType(); + + Assert.False(column.IsSortable()); + } + + [Fact] + public void TitlePropertyBinding() + { + var column = new DataGridColumn(); + column.CheckPropertyBindingWorks(DataGridColumn.TitleProperty, "Column A", "Column B"); + } + + [Fact] + public void WidthPropertyBinding() + { + var column = new DataGridColumn(); + column.CheckPropertyBindingWorks(DataGridColumn.WidthProperty, new GridLength(100), new GridLength(200)); + } + + [Fact] + public void IsVisiblePropertyBinding() + { + var column = new DataGridColumn(); + column.CheckPropertyBindingWorks(DataGridColumn.IsVisibleProperty, false, true); + } + + [Fact] + public void SortingEnabledPropertyBinding() + { + var column = new DataGridColumn(); + column.CheckPropertyBindingWorks(DataGridColumn.SortingEnabledProperty, false, true); + } + + public class TestItem + { + public int Value { get; set; } + } + + public class TestItemWithNonComparable + { + public object? NonComparable { get; set; } + } +} diff --git a/Maui.DataGrid.Sample/Tests/LayoutOptionsExtensionsTest.cs b/Maui.DataGrid.Sample/Tests/LayoutOptionsExtensionsTest.cs new file mode 100644 index 0000000..5281d06 --- /dev/null +++ b/Maui.DataGrid.Sample/Tests/LayoutOptionsExtensionsTest.cs @@ -0,0 +1,39 @@ +namespace Maui.DataGrid.Sample.Tests; + +using Maui.DataGrid.Extensions; +using Xunit; + +public class LayoutOptionsExtensionsTest +{ + [Fact] + public void StartLayoutReturnsStartTextAlignment() + { + var result = LayoutOptions.Start.ToTextAlignment(); + + Assert.Equal(TextAlignment.Start, result); + } + + [Fact] + public void EndLayoutReturnsEndTextAlignment() + { + var result = LayoutOptions.End.ToTextAlignment(); + + Assert.Equal(TextAlignment.End, result); + } + + [Fact] + public void CenterLayoutReturnsCenterTextAlignment() + { + var result = LayoutOptions.Center.ToTextAlignment(); + + Assert.Equal(TextAlignment.Center, result); + } + + [Fact] + public void FillLayoutReturnsCenterTextAlignment() + { + var result = LayoutOptions.Fill.ToTextAlignment(); + + Assert.Equal(TextAlignment.Center, result); + } +} diff --git a/Maui.DataGrid.Sample/Tests/ListExtensionsTest.cs b/Maui.DataGrid.Sample/Tests/ListExtensionsTest.cs new file mode 100644 index 0000000..9cba3a9 --- /dev/null +++ b/Maui.DataGrid.Sample/Tests/ListExtensionsTest.cs @@ -0,0 +1,185 @@ +namespace Maui.DataGrid.Sample.Tests; + +using Maui.DataGrid.Extensions; +using Xunit; + +public class ListExtensionsTest +{ + [Fact] + public void TryGetItem_ValidIndexReturnsTrue() + { + var label = new Label(); + IList list = [label, new BoxView()]; + + var result = list.TryGetItem(0, out var item); + + Assert.True(result); + Assert.Same(label, item); + } + + [Fact] + public void TryGetItem_LastIndexReturnsTrue() + { + var boxView = new BoxView(); + IList list = [new Label(), boxView]; + + var result = list.TryGetItem(1, out var item); + + Assert.True(result); + Assert.Same(boxView, item); + } + + [Fact] + public void TryGetItem_NegativeIndexReturnsFalse() + { + IList list = [new Label()]; + + var result = list.TryGetItem(-1, out var item); + + Assert.False(result); + Assert.Null(item); + } + + [Fact] + public void TryGetItem_IndexOutOfRangeReturnsFalse() + { + IList list = [new Label()]; + + var result = list.TryGetItem(5, out var item); + + Assert.False(result); + Assert.Null(item); + } + + [Fact] + public void TryGetItem_EmptyListReturnsFalse() + { + IList list = []; + + var result = list.TryGetItem(0, out var item); + + Assert.False(result); + Assert.Null(item); + } + + [Fact] + public void AddOrUpdate_AddsWhenIndexBeyondCount() + { + var columnDefs = new ColumnDefinitionCollection(); + var newDef = new ColumnDefinition(GridLength.Star); + + columnDefs.AddOrUpdate(newDef, 0); + + Assert.Single(columnDefs); + Assert.Equal(newDef, columnDefs[0]); + } + + [Fact] + public void AddOrUpdate_UpdatesWhenDifferent() + { + var oldDef = new ColumnDefinition(GridLength.Auto); + var newDef = new ColumnDefinition(GridLength.Star); + var columnDefs = new ColumnDefinitionCollection { oldDef }; + + columnDefs.AddOrUpdate(newDef, 0); + + Assert.Single(columnDefs); + Assert.Equal(newDef, columnDefs[0]); + } + + [Fact] + public void AddOrUpdate_DoesNothingWhenSame() + { + var def = new ColumnDefinition(GridLength.Star); + var columnDefs = new ColumnDefinitionCollection { def }; + + columnDefs.AddOrUpdate(def, 0); + + Assert.Single(columnDefs); + Assert.Same(def, columnDefs[0]); + } + + [Fact] + public void RemoveAfter_RemovesTrailingDefinitions() + { + var columnDefs = new ColumnDefinitionCollection + { + new(GridLength.Star), + new(GridLength.Auto), + new(GridLength.Star), + new(GridLength.Auto), + }; + + columnDefs.RemoveAfter(2); + + Assert.Equal(2, columnDefs.Count); + } + + [Fact] + public void RemoveAfter_RemovesAllWhenIndexIsOne() + { + var columnDefs = new ColumnDefinitionCollection + { + new(GridLength.Star), + new(GridLength.Auto), + new(GridLength.Star), + }; + + columnDefs.RemoveAfter(1); + + Assert.Single(columnDefs); + } + + [Fact] + public void RemoveAfter_DoesNothingWhenIndexEqualsCount() + { + var columnDefs = new ColumnDefinitionCollection + { + new(GridLength.Star), + new(GridLength.Auto), + }; + + columnDefs.RemoveAfter(2); + + Assert.Equal(2, columnDefs.Count); + } + + [Fact] + public void RemoveAfter_DoesNothingWhenIndexBeyondCount() + { + var columnDefs = new ColumnDefinitionCollection + { + new(GridLength.Star), + }; + + columnDefs.RemoveAfter(5); + + Assert.Single(columnDefs); + } + + [Fact] + public void TryGetItem_ExactCountIndexReturnsFalse() + { + IList list = [new Label(), new BoxView()]; + + var result = list.TryGetItem(2, out var item); + + Assert.False(result); + Assert.Null(item); + } + + [Fact] + public void AddOrUpdate_AddsMultipleSequentially() + { + var columnDefs = new ColumnDefinitionCollection(); + var def1 = new ColumnDefinition(GridLength.Star); + var def2 = new ColumnDefinition(GridLength.Auto); + + columnDefs.AddOrUpdate(def1, 0); + columnDefs.AddOrUpdate(def2, 1); + + Assert.Equal(2, columnDefs.Count); + Assert.Equal(def1, columnDefs[0]); + Assert.Equal(def2, columnDefs[1]); + } +} diff --git a/Maui.DataGrid.Sample/Tests/ObservableRangeCollectionTest.cs b/Maui.DataGrid.Sample/Tests/ObservableRangeCollectionTest.cs new file mode 100644 index 0000000..caedbcf --- /dev/null +++ b/Maui.DataGrid.Sample/Tests/ObservableRangeCollectionTest.cs @@ -0,0 +1,286 @@ +namespace Maui.DataGrid.Sample.Tests; + +using System.Collections.Specialized; +using Maui.DataGrid.Collections; +using Xunit; + +public class ObservableRangeCollectionTest +{ + [Fact] + public void AddRangeAddsItems() + { + var collection = new ObservableRangeCollection(); + + collection.AddRange([1, 2, 3]); + + Assert.Equal(3, collection.Count); + Assert.Equal(1, collection[0]); + Assert.Equal(2, collection[1]); + Assert.Equal(3, collection[2]); + } + + [Fact] + public void AddRangeRaisesAddNotification() + { + var collection = new ObservableRangeCollection { 10 }; + NotifyCollectionChangedEventArgs? args = null; + + collection.CollectionChanged += (s, e) => args = e; + collection.AddRange([20, 30], NotifyCollectionChangedAction.Add); + + Assert.NotNull(args); + Assert.Equal(NotifyCollectionChangedAction.Add, args!.Action); + Assert.Equal(new[] { 20, 30 }, args.NewItems!.Cast()); + Assert.Equal(1, args.NewStartingIndex); + } + + [Fact] + public void AddRangeRaisesResetNotification() + { + var collection = new ObservableRangeCollection(); + NotifyCollectionChangedEventArgs? args = null; + + collection.CollectionChanged += (s, e) => args = e; + collection.AddRange([1, 2], NotifyCollectionChangedAction.Reset); + + Assert.NotNull(args); + Assert.Equal(NotifyCollectionChangedAction.Reset, args!.Action); + } + + [Fact] + public void AddRangeEmptyCollectionDoesNotRaiseEvent() + { + var collection = new ObservableRangeCollection(); + var eventRaised = false; + + collection.CollectionChanged += (s, e) => eventRaised = true; + collection.AddRange([]); + + Assert.False(eventRaised); + } + + [Fact] + public void AddRangeThrowsForInvalidMode() + { + var collection = new ObservableRangeCollection(); + + var ex = Assert.Throws(() => + collection.AddRange(new[] { 1 }, NotifyCollectionChangedAction.Remove)); + Assert.Contains("Mode must be either Add or Reset", ex.Message); + } + + [Fact] + public void AddRangeThrowsForNullCollection() + { + var collection = new ObservableRangeCollection(); + + Assert.Throws(() => + collection.AddRange(null!)); + } + + [Fact] + public void RemoveRangeRemovesItems() + { + var collection = new ObservableRangeCollection { 1, 2, 3, 4 }; + + collection.RemoveRange([2, 4]); + + Assert.Equal(2, collection.Count); + Assert.Contains(1, collection); + Assert.Contains(3, collection); + } + + [Fact] + public void RemoveRangeRaisesResetNotification() + { + var collection = new ObservableRangeCollection { 1, 2, 3 }; + NotifyCollectionChangedEventArgs? args = null; + + collection.CollectionChanged += (s, e) => args = e; + collection.RemoveRange([2], NotifyCollectionChangedAction.Reset); + + Assert.NotNull(args); + Assert.Equal(NotifyCollectionChangedAction.Reset, args!.Action); + } + + [Fact] + public void RemoveRangeRaisesRemoveNotification() + { + var collection = new ObservableRangeCollection { 1, 2, 3 }; + NotifyCollectionChangedEventArgs? args = null; + + collection.CollectionChanged += (s, e) => args = e; + collection.RemoveRange([2, 3], NotifyCollectionChangedAction.Remove); + + Assert.NotNull(args); + Assert.Equal(NotifyCollectionChangedAction.Remove, args!.Action); + Assert.Equal(new[] { 2, 3 }, args.OldItems!.Cast()); + } + + [Fact] + public void RemoveRangeWithRemoveModeExcludesNonExistingItems() + { + var collection = new ObservableRangeCollection { 1, 2, 3 }; + NotifyCollectionChangedEventArgs? args = null; + + collection.CollectionChanged += (s, e) => args = e; + collection.RemoveRange([2, 99], NotifyCollectionChangedAction.Remove); + + Assert.NotNull(args); + Assert.Equal(new[] { 2 }, args!.OldItems!.Cast()); + } + + [Fact] + public void RemoveRangeNoMatchesDoesNotRaiseEvent() + { + var collection = new ObservableRangeCollection { 1, 2, 3 }; + var eventRaised = false; + + collection.CollectionChanged += (s, e) => eventRaised = true; + collection.RemoveRange([99, 100]); + + Assert.False(eventRaised); + } + + [Fact] + public void RemoveRangeThrowsForInvalidMode() + { + var collection = new ObservableRangeCollection { 1, 2 }; + + Assert.Throws(() => + collection.RemoveRange([1], NotifyCollectionChangedAction.Add)); + } + + [Fact] + public void RemoveRangeThrowsForNullCollection() + { + var collection = new ObservableRangeCollection(); + + Assert.Throws(() => + collection.RemoveRange(null!)); + } + + [Fact] + public void ReplaceReplacesAllWithSingleItem() + { + var collection = new ObservableRangeCollection { 1, 2, 3 }; + + collection.Replace(42); + + Assert.Single(collection); + Assert.Equal(42, collection[0]); + } + + [Fact] + public void ReplaceRangeReplacesAllItems() + { + var collection = new ObservableRangeCollection { 1, 2, 3 }; + NotifyCollectionChangedEventArgs? args = null; + + collection.CollectionChanged += (s, e) => args = e; + collection.ReplaceRange([10, 20]); + + Assert.Equal(2, collection.Count); + Assert.Equal(10, collection[0]); + Assert.Equal(20, collection[1]); + Assert.NotNull(args); + Assert.Equal(NotifyCollectionChangedAction.Reset, args!.Action); + } + + [Fact] + public void ReplaceRangeEmptyToEmptyDoesNotRaiseEvent() + { + var collection = new ObservableRangeCollection(); + var eventRaised = false; + + collection.CollectionChanged += (s, e) => eventRaised = true; + collection.ReplaceRange([]); + + Assert.False(eventRaised); + } + + [Fact] + public void ReplaceRangeNonEmptyToEmptyRaisesEvent() + { + var collection = new ObservableRangeCollection { 1, 2 }; + var eventRaised = false; + + collection.CollectionChanged += (s, e) => eventRaised = true; + collection.ReplaceRange([]); + + Assert.True(eventRaised); + Assert.Empty(collection); + } + + [Fact] + public void ReplaceRangeThrowsForNullCollection() + { + var collection = new ObservableRangeCollection(); + + Assert.Throws(() => + collection.ReplaceRange(null!)); + } + + [Fact] + public void ConstructorWithCollectionCopiesItems() + { + var source = new[] { 10, 20, 30 }; + + var collection = new ObservableRangeCollection(source); + + Assert.Equal(3, collection.Count); + Assert.Equal(10, collection[0]); + Assert.Equal(20, collection[1]); + Assert.Equal(30, collection[2]); + } + + [Fact] + public void AddRangeRaisesPropertyChangedForCount() + { + var collection = new ObservableRangeCollection(); + var propertyNames = new List(); + + ((System.ComponentModel.INotifyPropertyChanged)collection).PropertyChanged += (s, e) => propertyNames.Add(e.PropertyName!); + collection.AddRange([1, 2]); + + Assert.Contains("Count", propertyNames); + Assert.Contains("Item[]", propertyNames); + } + + [Fact] + public void RemoveRangeEmptyCollectionDoesNotRaiseEvent() + { + var collection = new ObservableRangeCollection(); + var eventRaised = false; + + collection.CollectionChanged += (s, e) => eventRaised = true; + collection.RemoveRange([]); + + Assert.False(eventRaised); + } + + [Fact] + public void ReplaceRangeRaisesPropertyChangedForCount() + { + var collection = new ObservableRangeCollection { 1, 2 }; + var propertyNames = new List(); + + ((System.ComponentModel.INotifyPropertyChanged)collection).PropertyChanged += (s, e) => propertyNames.Add(e.PropertyName!); + collection.ReplaceRange([10, 20, 30]); + + Assert.Contains("Count", propertyNames); + Assert.Contains("Item[]", propertyNames); + } + + [Fact] + public void AddRangeAppendsToExistingItems() + { + var collection = new ObservableRangeCollection { 1, 2 }; + + collection.AddRange([3, 4]); + + Assert.Equal(4, collection.Count); + Assert.Equal(1, collection[0]); + Assert.Equal(4, collection[3]); + } +} diff --git a/Maui.DataGrid.Sample/Tests/ReflectionExtensionsTest.cs b/Maui.DataGrid.Sample/Tests/ReflectionExtensionsTest.cs new file mode 100644 index 0000000..8347d5c --- /dev/null +++ b/Maui.DataGrid.Sample/Tests/ReflectionExtensionsTest.cs @@ -0,0 +1,184 @@ +namespace Maui.DataGrid.Sample.Tests; + +using Maui.DataGrid.Extensions; +using Xunit; + +public class ReflectionExtensionsTest +{ + private class Deepest + { + public int Score { get; set; } + } + + private class Inner + { + public int Value { get; set; } + public string? Text { get; set; } + public Deepest? Nested { get; set; } + } + + private class Outer + { + public Inner? Child { get; set; } + public string? Name { get; set; } + } + + [Fact] + public void GetValueByPath_SimpleProperty() + { + var obj = new Outer { Name = "test" }; + + var result = obj.GetValueByPath("Name"); + + Assert.Equal("test", result); + } + + [Fact] + public void GetValueByPath_NestedProperty() + { + var obj = new Outer { Child = new Inner { Value = 42 } }; + + var result = obj.GetValueByPath("Child.Value"); + + Assert.Equal(42, result); + } + + [Fact] + public void GetValueByPath_DeepNestedStringProperty() + { + var obj = new Outer { Child = new Inner { Text = "hello" } }; + + var result = obj.GetValueByPath("Child.Text"); + + Assert.Equal("hello", result); + } + + [Fact] + public void GetValueByPath_NullIntermediateReturnsNull() + { + var obj = new Outer { Child = null }; + + var result = obj.GetValueByPath("Child.Value"); + + Assert.Null(result); + } + + [Fact] + public void GetValueByPath_NullObjectReturnsNull() + { + object? obj = null; + + var result = obj!.GetValueByPath("Name"); + + Assert.Null(result); + } + + [Fact] + public void GetValueByPath_EmptyPathReturnsNull() + { + var obj = new Outer { Name = "test" }; + + var result = obj.GetValueByPath(""); + + Assert.Null(result); + } + + [Fact] + public void GetValueByPath_NonExistentPropertyReturnsNull() + { + var obj = new Outer { Name = "test" }; + + var result = obj.GetValueByPath("NonExistent"); + + Assert.Null(result); + } + + [Fact] + public void GetPropertyTypeByPath_SimpleProperty() + { + var result = typeof(Outer).GetPropertyTypeByPath("Name"); + + Assert.Equal(typeof(string), result); + } + + [Fact] + public void GetPropertyTypeByPath_NestedProperty() + { + var result = typeof(Outer).GetPropertyTypeByPath("Child.Value"); + + Assert.Equal(typeof(int), result); + } + + [Fact] + public void GetPropertyTypeByPath_DotReturnsTypeSelf() + { + var result = typeof(Outer).GetPropertyTypeByPath("."); + + Assert.Equal(typeof(Outer), result); + } + + [Fact] + public void GetPropertyTypeByPath_EmptyPathReturnsTypeSelf() + { + var result = typeof(Outer).GetPropertyTypeByPath(""); + + Assert.Equal(typeof(Outer), result); + } + + [Fact] + public void GetPropertyTypeByPath_NonExistentPropertyReturnsNull() + { + var result = typeof(Outer).GetPropertyTypeByPath("DoesNotExist"); + + Assert.Null(result); + } + + [Fact] + public void GetPropertyTypeByPath_NonExistentNestedPropertyReturnsNull() + { + var result = typeof(Outer).GetPropertyTypeByPath("Child.DoesNotExist"); + + Assert.Null(result); + } + + [Fact] + public void GetValueByPath_ThreeLevelDeepProperty() + { + var obj = new Outer { Child = new Inner { Nested = new Deepest { Score = 99 } } }; + + var result = obj.GetValueByPath("Child.Nested.Score"); + + Assert.Equal(99, result); + } + + [Fact] + public void GetValueByPath_ThreeLevelDeepNullIntermediateReturnsNull() + { + var obj = new Outer { Child = new Inner { Nested = null } }; + + var result = obj.GetValueByPath("Child.Nested.Score"); + + Assert.Null(result); + } + + [Fact] + public void GetPropertyTypeByPath_ThreeLevelDeepProperty() + { + var result = typeof(Outer).GetPropertyTypeByPath("Child.Nested.Score"); + + Assert.Equal(typeof(int), result); + } + + [Fact] + public void GetValueByPath_CachesPropertyPath() + { + var obj1 = new Outer { Child = new Inner { Value = 10 } }; + var obj2 = new Outer { Child = new Inner { Value = 20 } }; + + var result1 = obj1.GetValueByPath("Child.Value"); + var result2 = obj2.GetValueByPath("Child.Value"); + + Assert.Equal(10, result1); + Assert.Equal(20, result2); + } +} diff --git a/Maui.DataGrid.Sample/Tests/SortDataTest.cs b/Maui.DataGrid.Sample/Tests/SortDataTest.cs new file mode 100644 index 0000000..3e62a65 --- /dev/null +++ b/Maui.DataGrid.Sample/Tests/SortDataTest.cs @@ -0,0 +1,121 @@ +namespace Maui.DataGrid.Sample.Tests; + +using Xunit; + +public class SortDataTest +{ + [Fact] + public void ConstructorSetsProperties() + { + var sortData = new SortData(2, SortingOrder.Ascendant); + + Assert.Equal(2, sortData.Index); + Assert.Equal(SortingOrder.Ascendant, sortData.Order); + } + + [Fact] + public void FromInt32PositiveIndexReturnsAscendant() + { + var sortData = SortData.FromInt32(3); + + Assert.Equal(3, sortData.Index); + Assert.Equal(SortingOrder.Ascendant, sortData.Order); + } + + [Fact] + public void FromInt32NegativeIndexReturnsDescendant() + { + var sortData = SortData.FromInt32(-2); + + Assert.Equal(2, sortData.Index); + Assert.Equal(SortingOrder.Descendant, sortData.Order); + } + + [Fact] + public void FromInt32ZeroReturnsAscendant() + { + var sortData = SortData.FromInt32(0); + + Assert.Equal(0, sortData.Index); + Assert.Equal(SortingOrder.Ascendant, sortData.Order); + } + + [Fact] + public void ImplicitOperatorConvertsPositiveInt() + { + SortData sortData = 5; + + Assert.Equal(5, sortData.Index); + Assert.Equal(SortingOrder.Ascendant, sortData.Order); + } + + [Fact] + public void ImplicitOperatorConvertsNegativeInt() + { + SortData sortData = -1; + + Assert.Equal(1, sortData.Index); + Assert.Equal(SortingOrder.Descendant, sortData.Order); + } + + [Fact] + public void EqualsReturnsTrueForSameValues() + { + var a = new SortData(1, SortingOrder.Ascendant); + var b = new SortData(1, SortingOrder.Ascendant); + + Assert.True(a.Equals(b)); + } + + [Fact] + public void EqualsReturnsFalseForDifferentIndex() + { + var a = new SortData(1, SortingOrder.Ascendant); + var b = new SortData(2, SortingOrder.Ascendant); + + Assert.False(a.Equals(b)); + } + + [Fact] + public void EqualsReturnsFalseForDifferentOrder() + { + var a = new SortData(1, SortingOrder.Ascendant); + var b = new SortData(1, SortingOrder.Descendant); + + Assert.False(a.Equals(b)); + } + + [Fact] + public void EqualsReturnsFalseForNull() + { + var sortData = new SortData(1, SortingOrder.Ascendant); + + Assert.False(sortData.Equals(null)); + } + + [Fact] + public void EqualsReturnsFalseForDifferentType() + { + var sortData = new SortData(1, SortingOrder.Ascendant); + + Assert.False(sortData.Equals("not a SortData")); + } + + [Fact] + public void GetHashCodeSameForEqualObjects() + { + var a = new SortData(1, SortingOrder.Ascendant); + var b = new SortData(1, SortingOrder.Ascendant); + + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCodeDiffersForDifferentObjects() + { + var a = new SortData(1, SortingOrder.Ascendant); + var b = new SortData(2, SortingOrder.Descendant); + + Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); + } +} diff --git a/Maui.DataGrid/Converters/SortDataTypeConverter.cs b/Maui.DataGrid/Converters/SortDataTypeConverter.cs index 1ff740d..4ac8668 100644 --- a/Maui.DataGrid/Converters/SortDataTypeConverter.cs +++ b/Maui.DataGrid/Converters/SortDataTypeConverter.cs @@ -21,6 +21,22 @@ public sealed class SortDataTypeConverter : TypeConverter // This needs to be pu return (SortData)index; } + var str = value.ToString(); + + if (str != null) + { + var parts = str.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length == 2 && int.TryParse(parts[0], out var columnIndex)) + { + var order = parts[1].Equals("DESC", StringComparison.OrdinalIgnoreCase) + ? SortingOrder.Descendant + : SortingOrder.Ascendant; + + return new SortData(columnIndex, order); + } + } + return base.ConvertFrom(context, culture, value); } } diff --git a/Maui.DataGrid/DataGrid.xaml.cs b/Maui.DataGrid/DataGrid.xaml.cs index 81405b3..a192a4f 100644 --- a/Maui.DataGrid/DataGrid.xaml.cs +++ b/Maui.DataGrid/DataGrid.xaml.cs @@ -1,7 +1,6 @@ namespace Maui.DataGrid; using System.Collections; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; @@ -9,7 +8,6 @@ namespace Maui.DataGrid; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Reflection; using System.Windows.Input; using Maui.DataGrid.Collections; using Maui.DataGrid.Extensions; @@ -173,8 +171,15 @@ public partial class DataGrid return; } - // Reset internal hash set, used for fast lookups + // Reset caches self._internalItemsHashSet = null; + self._originalItemsCache = null; + + // Reset column data types so they are re-resolved for the new source + foreach (var column in self.Columns) + { + column.ResetDataType(); + } // Unsubscribe from old collection's change event if (o is INotifyCollectionChanged oldCollection) @@ -418,11 +423,11 @@ public partial class DataGrid var internalItems = self.GetInternalItems(v.Count); - foreach (var selectedItem in selectedItems) + for (var i = selectedItems.Count - 1; i >= 0; i--) { - if (!internalItems.Contains(selectedItem)) + if (!internalItems.Contains(selectedItems[i])) { - _ = selectedItems.Remove(selectedItem); + selectedItems.RemoveAt(i); } } @@ -685,12 +690,10 @@ public partial class DataGrid private readonly SortedSet _pageSizeList = [.. DefaultPageSizeSet]; - private readonly ConcurrentDictionary _propertyCache = []; - - private readonly Lock _reloadLock = new(); - private readonly Lock _sortAndPaginateLock = new(); private DataGridColumn? _sortedColumn; private HashSet? _internalItemsHashSet; + private IList? _originalItemsCache; + private Dictionary? _internalItemsIndexMap; /// /// Initializes a new instance of the class. @@ -1212,6 +1215,25 @@ public string PerPageText internal ObservableRangeCollection InternalItems { get; } = []; + internal int GetItemIndex(object? item) + { + if (item == null) + { + return -1; + } + + if (_internalItemsIndexMap == null) + { + _internalItemsIndexMap = new(InternalItems.Count); + for (var i = 0; i < InternalItems.Count; i++) + { + _internalItemsIndexMap[InternalItems[i]] = i; + } + } + + return _internalItemsIndexMap.TryGetValue(item, out var index) ? index : -1; + } + /// /// Scrolls to the row. /// @@ -1227,12 +1249,9 @@ internal void Initialize() return; } - lock (_reloadLock) - { - UpdatePageSizeList(); + UpdatePageSizeList(); - _headerRow.InitializeHeaderRow(); - } + _headerRow.InitializeHeaderRow(); } internal void SortFilterAndPaginate(SortData? sortData = null) @@ -1242,29 +1261,28 @@ internal void SortFilterAndPaginate(SortData? sortData = null) return; } - lock (_sortAndPaginateLock) - { - sortData ??= SortedColumnIndex; + sortData ??= SortedColumnIndex; - var originalItems = ItemsSource as IList ?? [.. ItemsSource.Cast()]; + var originalItems = ItemsSource as IList ?? (_originalItemsCache ??= [.. ItemsSource.Cast()]); - if (originalItems.Count == 0) - { - PageCount = 1; - InternalItems.Clear(); - return; - } + if (originalItems.Count == 0) + { + PageCount = 1; + _internalItemsIndexMap = null; + InternalItems.Clear(); + return; + } - var filteredItems = CanFilter() ? GetFilteredItems(originalItems) : originalItems; + var filteredItems = CanFilter() ? GetFilteredItems(originalItems) : originalItems; - var sortedItems = CanSort(sortData) ? GetSortedItems(filteredItems, sortData!) : filteredItems; + var sortedItems = CanSort(sortData) ? GetSortedItems(filteredItems, sortData!) : filteredItems; - var paginatedItems = PaginationEnabled ? GetPaginatedItems(sortedItems) : sortedItems; + var paginatedItems = PaginationEnabled ? GetPaginatedItems(sortedItems) : sortedItems; - PageCount = (int)Math.Ceiling(filteredItems.Count / (double)PageSize); + PageCount = (int)Math.Ceiling(filteredItems.Count / (double)PageSize); - InternalItems.ReplaceRange(paginatedItems); - } + _internalItemsIndexMap = null; + InternalItems.ReplaceRange(paginatedItems); } /// @@ -1359,6 +1377,7 @@ private void OnSelectionChanged(object? sender, SelectionChangedEventArgs e) private void OnItemsSourceCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { _internalItemsHashSet = null; + _originalItemsCache = null; SortFilterAndPaginate(); } @@ -1501,6 +1520,7 @@ private IEnumerable GetSortedItems(IList unsortedItems, SortData private IList GetFilteredItems(IList originalItems) { var filteredItems = originalItems.AsEnumerable(); + var hasFilter = false; foreach (var column in Columns) { @@ -1509,46 +1529,22 @@ private IList GetFilteredItems(IList originalItems) continue; } + hasFilter = true; filteredItems = filteredItems.Where(item => FilterItem(item, column)); } - return [.. filteredItems]; + return hasFilter ? [.. filteredItems] : originalItems; } - [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Reflection is needed here.")] - private bool FilterItem(object item, DataGridColumn column) + private static bool FilterItem(object item, DataGridColumn column) { - try - { - if (string.IsNullOrEmpty(column.FilterText)) - { - return true; - } - - var itemType = item.GetType(); - var cacheKey = $"{itemType.FullName}|{column.PropertyName}"; - - if (!_propertyCache.TryGetValue(cacheKey, out var property)) - { - property = itemType.GetProperty(column.PropertyName); - _propertyCache[cacheKey] = property; - } - - if (property == null || property.PropertyType == typeof(object)) - { - return false; - } - - var value = property.GetValue(item)?.ToString(); - return value?.Contains(column.FilterText, StringComparison.OrdinalIgnoreCase) == true; - } -#pragma warning disable CA1031 // Do not catch general exception types - catch (Exception ex) + if (string.IsNullOrEmpty(column.FilterText)) { - Debug.WriteLine(ex); - return false; + return true; } -#pragma warning restore CA1031 // Do not catch general exception types + + var value = item.GetValueByPath(column.PropertyName)?.ToString(); + return value?.Contains(column.FilterText, StringComparison.OrdinalIgnoreCase) == true; } private IEnumerable GetPaginatedItems(IEnumerable unpaginatedItems) diff --git a/Maui.DataGrid/DataGridColumn.cs b/Maui.DataGrid/DataGridColumn.cs index 4a02fa5..8f3113d 100644 --- a/Maui.DataGrid/DataGridColumn.cs +++ b/Maui.DataGrid/DataGridColumn.cs @@ -552,6 +552,12 @@ internal void InitializeDataType() } } + internal void ResetDataType() + { + DataType = null; + _isSortable = null; + } + private void OnSizeChanged() => _sizeChangedEventManager.HandleEvent(this, EventArgs.Empty, nameof(SizeChanged)); private void OnVisibilityChanged() => _visibilityChangedEventManager.HandleEvent(this, EventArgs.Empty, nameof(VisibilityChanged)); diff --git a/Maui.DataGrid/DataGridHeaderRow.cs b/Maui.DataGrid/DataGridHeaderRow.cs index 62680c0..2aa6279 100644 --- a/Maui.DataGrid/DataGridHeaderRow.cs +++ b/Maui.DataGrid/DataGridHeaderRow.cs @@ -54,6 +54,7 @@ internal void InitializeHeaderRow(bool force = false) } var columnCount = DataGrid.Columns.Count; + var anyColumnFilterable = DataGrid.Columns.Any(c => c.FilteringEnabled); for (var i = 0; i < columnCount; i++) { @@ -70,7 +71,7 @@ internal void InitializeHeaderRow(bool force = false) // Add or update columns as needed ColumnDefinitions.AddOrUpdate(col.ColumnDefinition, i); - col.HeaderCell = CreateHeaderCell(col); + col.HeaderCell = CreateHeaderCell(col, anyColumnFilterable); col.HeaderCell.UpdateBindings(DataGrid); @@ -115,16 +116,15 @@ protected override void OnParentSet() { base.OnParentSet(); - if (Parent == null) - { - DataGrid.Columns.CollectionChanged -= OnColumnsChanged; + // Always unsubscribe first to prevent duplicate handlers + DataGrid.Columns.CollectionChanged -= OnColumnsChanged; - foreach (var column in DataGrid.Columns) - { - column.VisibilityChanged -= OnVisibilityChanged; - } + foreach (var column in DataGrid.Columns) + { + column.VisibilityChanged -= OnVisibilityChanged; } - else + + if (Parent != null) { DataGrid.Columns.CollectionChanged += OnColumnsChanged; @@ -175,11 +175,11 @@ private void OnVisibilityChanged(object? sender, EventArgs e) InitializeHeaderRow(); } - private DataGridCell CreateHeaderCell(DataGridColumn column) + private DataGridCell CreateHeaderCell(DataGridColumn column, bool anyColumnFilterable) { if (column.HeaderCell != null) { - SetFilterRow(column); + SetFilterRow(column, anyColumnFilterable); return column.HeaderCell; } @@ -212,7 +212,7 @@ private DataGridCell CreateHeaderCell(DataGridColumn column) cellContent.Children.Add(column.HeaderLabelContainer); - SetFilterRow(column); + SetFilterRow(column, anyColumnFilterable); cellContent.Children.Add(column.FilterTextboxContainer); cellContent.SetRow(column.FilterTextboxContainer, 1); @@ -221,13 +221,13 @@ private DataGridCell CreateHeaderCell(DataGridColumn column) return new DataGridCell(cellContent, DataGrid.HeaderBackground, column, false); } - private void SetFilterRow(DataGridColumn column) + private void SetFilterRow(DataGridColumn column, bool anyColumnFilterable) { if (DataGrid.FilteringEnabled && column.FilteringEnabled) { column.FilterTextboxContainer.Content = column.FilterTextbox; } - else if (DataGrid.FilteringEnabled && DataGrid.Columns.Any(c => c.FilteringEnabled)) + else if (DataGrid.FilteringEnabled && anyColumnFilterable) { // Add placeholder column.FilterTextboxContainer.Content = new Entry diff --git a/Maui.DataGrid/DataGridRow.cs b/Maui.DataGrid/DataGridRow.cs index 28d53bb..c4f247e 100644 --- a/Maui.DataGrid/DataGridRow.cs +++ b/Maui.DataGrid/DataGridRow.cs @@ -146,19 +146,18 @@ protected override void OnParentSet() { base.OnParentSet(); - if (Parent == null) - { - DataGrid.ItemSelected -= DataGrid_ItemSelected; - DataGrid.Columns.CollectionChanged -= OnColumnsChanged; - DataGrid.RowsBackgroundColorPaletteChanged -= OnRowsBackgroundColorPaletteChanged; - DataGrid.RowsTextColorPaletteChanged -= OnRowsTextColorPaletteChanged; + // Always unsubscribe first to prevent duplicate handlers + DataGrid.ItemSelected -= DataGrid_ItemSelected; + DataGrid.Columns.CollectionChanged -= OnColumnsChanged; + DataGrid.RowsBackgroundColorPaletteChanged -= OnRowsBackgroundColorPaletteChanged; + DataGrid.RowsTextColorPaletteChanged -= OnRowsTextColorPaletteChanged; - foreach (var column in DataGrid.Columns) - { - column.VisibilityChanged -= OnVisibilityChanged; - } + foreach (var column in DataGrid.Columns) + { + column.VisibilityChanged -= OnVisibilityChanged; } - else + + if (Parent != null) { DataGrid.ItemSelected += DataGrid_ItemSelected; DataGrid.Columns.CollectionChanged += OnColumnsChanged; @@ -425,7 +424,7 @@ private void SetBinding(DataGridColumn col, View view, BindableProperty bindable private void UpdateColors() { - var rowIndex = DataGrid.InternalItems.IndexOf(BindingContext); + var rowIndex = DataGrid.GetItemIndex(BindingContext); if (rowIndex == -1) { diff --git a/Maui.DataGrid/Extensions/ReflectionExtensions.cs b/Maui.DataGrid/Extensions/ReflectionExtensions.cs index 89e39ea..307666e 100644 --- a/Maui.DataGrid/Extensions/ReflectionExtensions.cs +++ b/Maui.DataGrid/Extensions/ReflectionExtensions.cs @@ -1,6 +1,7 @@ namespace Maui.DataGrid.Extensions; using System; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -8,6 +9,8 @@ internal static class ReflectionExtensions { private const char PropertyOfOp = '.'; + private static readonly ConcurrentDictionary<(Type Type, string Path), PropertyInfo[]?> PropertyPathCache = new(); + [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Reflection is required here.")] public static object? GetValueByPath(this object obj, string path) { @@ -16,13 +19,18 @@ internal static class ReflectionExtensions return null; } - var result = obj; + var properties = PropertyPathCache.GetOrAdd((obj.GetType(), path), static key => ResolvePropertyPath(key.Type, key.Path)); - foreach (var token in path.Split(PropertyOfOp)) + if (properties == null) { - var resultType = result.GetType().GetProperty(token, BindingFlags.Public | BindingFlags.Instance); + return null; + } - result = resultType?.GetValue(result); + var result = obj; + + foreach (var property in properties) + { + result = property.GetValue(result); if (result == null) { @@ -62,4 +70,27 @@ internal static class ReflectionExtensions return resultType; } + + [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Reflection is required here.")] + private static PropertyInfo[]? ResolvePropertyPath([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, string path) + { + var tokens = path.Split(PropertyOfOp); + var properties = new PropertyInfo[tokens.Length]; + var currentType = type; + + for (var i = 0; i < tokens.Length; i++) + { + var property = currentType.GetProperty(tokens[i], BindingFlags.Public | BindingFlags.Instance); + + if (property == null) + { + return null; + } + + properties[i] = property; + currentType = property.PropertyType; + } + + return properties; + } }