From be2052b1b0109a4b99c6755c3b52b4f07da2b613 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Thu, 9 May 2024 20:31:42 +1000 Subject: [PATCH 01/26] Add sort range command --- .../Commands/SortRangeCommand.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs diff --git a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs new file mode 100644 index 00000000..3aa319c8 --- /dev/null +++ b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs @@ -0,0 +1,56 @@ +using BlazorDatasheet.Core.Data; +using BlazorDatasheet.DataStructures.Geometry; + +namespace BlazorDatasheet.Core.Commands; + +public class SortRangeCommand : IUndoableCommand +{ + private IRegion _region; + private List? _sortOptions = new List(); + + /// + /// Sorts the specified region on values using the specified sort options. + /// + /// The region to sort + /// The column sort options, if null the default sort (sort on column 0 ascending) will be used. + public SortRangeCommand(IRegion region, List? sortOptions = null) + { + _region = region; + _sortOptions = sortOptions ?? new List() + { new(0, true) }; + } + + public bool Execute(Sheet sheet) + { + return true; + } + + public bool Undo(Sheet sheet) + { + return true; + } +} + +public class ColumnSortOptions +{ + /// + /// The column index, relative to the range being sorted. + /// + public int ColumnIndex { get; set; } + + /// + /// Whether to sort in ascending order. + /// + public bool Ascending { get; set; } + + /// + /// + /// + /// + /// + public ColumnSortOptions(int columnIndex, bool ascending) + { + ColumnIndex = columnIndex; + Ascending = ascending; + } +} \ No newline at end of file From 4f7782284e431ce140fd114d9475350589fefc56 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Thu, 9 May 2024 20:31:55 +1000 Subject: [PATCH 02/26] Add sort range tests --- .../SheetTests/RangeSortingTests.cs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 test/BlazorDatasheet.Test/SheetTests/RangeSortingTests.cs diff --git a/test/BlazorDatasheet.Test/SheetTests/RangeSortingTests.cs b/test/BlazorDatasheet.Test/SheetTests/RangeSortingTests.cs new file mode 100644 index 00000000..c800c225 --- /dev/null +++ b/test/BlazorDatasheet.Test/SheetTests/RangeSortingTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using BlazorDatasheet.Core.Commands; +using BlazorDatasheet.Core.Data; +using BlazorDatasheet.DataStructures.Geometry; +using FluentAssertions; +using NUnit.Framework; + +namespace BlazorDatasheet.Test.SheetTests; + +public class RangeSortingTests +{ + [Test] + public void Sort_Range_Values_Only_Sorts() + { + var sheet = new Sheet(10, 10); + for (int row = 0; row < sheet.NumRows; row++) + { + sheet.Cells[row, 0].Value = sheet.NumRows - row; + sheet.Cells[row, 1].Value = row; + } + + var region = new ColumnRegion(0, 1); + var options = new List + { + new ColumnSortOptions(0, true) + }; + + var sortRangeCommand = new SortRangeCommand(region, options); + sortRangeCommand.Execute(sheet); + + for (int row = 0; row < sheet.NumRows; row++) + { + sheet.Cells[row, 0].Value.Should().Be(row); + sheet.Cells[row, 1].Value.Should().Be(sheet.NumRows - row); + } + + sortRangeCommand.Undo(sheet); + for (int row = 0; row < sheet.NumRows; row++) + { + sheet.Cells[row, 0].Value.Should().Be(sheet.NumRows - row); + sheet.Cells[row, 1].Value.Should().Be(row); + } + } + + // test that a sort on a single column with some empty cells works + [Test] + public void Sort_Col_With_Empty_Rows_Results_In_Continuous_Rows() + { + var sheet = new Sheet(5, 1); + sheet.Cells[0, 0].Value = 5; + sheet.Cells[1, 0].Value = 4; + sheet.Cells[3, 0].Value = 3; + + var region = new ColumnRegion(0, 0); + var options = new List + { + new ColumnSortOptions(0, true) + }; + + var cmd = new SortRangeCommand(region, options); + cmd.Execute(sheet); + + sheet.Cells[0, 0].Value.Should().Be(3); + sheet.Cells[1, 0].Value.Should().Be(4); + sheet.Cells[2, 0].Value.Should().Be(5); + sheet.Cells[3, 0].Value.Should().BeNull(); + + cmd.Undo(sheet); + sheet.Cells[0, 0].Value.Should().Be(5); + sheet.Cells[1, 0].Value.Should().Be(4); + sheet.Cells[2, 0].Value.Should().BeNull(); + sheet.Cells[3, 0].Value.Should().Be(3); + } + + [Test] + public void Sort_Empty_Sheet_Does_Not_Throw_Exception() + { + var sheet = new Sheet(10, 10); + + var region = new ColumnRegion(0, 0); + var options = new List + { + new ColumnSortOptions(0, true) + }; + + var sortRangeCommand = new SortRangeCommand(region, options); + + Action act = () => sortRangeCommand.Execute(sheet); + act.Should().NotThrow(); + } +} \ No newline at end of file From 8f60af10320400a9277246b9c94631cc26950a1b Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Thu, 9 May 2024 20:31:42 +1000 Subject: [PATCH 03/26] Add sort range command --- .../Commands/SortRangeCommand.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs diff --git a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs new file mode 100644 index 00000000..3aa319c8 --- /dev/null +++ b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs @@ -0,0 +1,56 @@ +using BlazorDatasheet.Core.Data; +using BlazorDatasheet.DataStructures.Geometry; + +namespace BlazorDatasheet.Core.Commands; + +public class SortRangeCommand : IUndoableCommand +{ + private IRegion _region; + private List? _sortOptions = new List(); + + /// + /// Sorts the specified region on values using the specified sort options. + /// + /// The region to sort + /// The column sort options, if null the default sort (sort on column 0 ascending) will be used. + public SortRangeCommand(IRegion region, List? sortOptions = null) + { + _region = region; + _sortOptions = sortOptions ?? new List() + { new(0, true) }; + } + + public bool Execute(Sheet sheet) + { + return true; + } + + public bool Undo(Sheet sheet) + { + return true; + } +} + +public class ColumnSortOptions +{ + /// + /// The column index, relative to the range being sorted. + /// + public int ColumnIndex { get; set; } + + /// + /// Whether to sort in ascending order. + /// + public bool Ascending { get; set; } + + /// + /// + /// + /// + /// + public ColumnSortOptions(int columnIndex, bool ascending) + { + ColumnIndex = columnIndex; + Ascending = ascending; + } +} \ No newline at end of file From b9f9abde779825782a579eb63e87c25ee40f9675 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Thu, 9 May 2024 20:31:55 +1000 Subject: [PATCH 04/26] Add sort range tests --- .../SheetTests/RangeSortingTests.cs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 test/BlazorDatasheet.Test/SheetTests/RangeSortingTests.cs diff --git a/test/BlazorDatasheet.Test/SheetTests/RangeSortingTests.cs b/test/BlazorDatasheet.Test/SheetTests/RangeSortingTests.cs new file mode 100644 index 00000000..c800c225 --- /dev/null +++ b/test/BlazorDatasheet.Test/SheetTests/RangeSortingTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using BlazorDatasheet.Core.Commands; +using BlazorDatasheet.Core.Data; +using BlazorDatasheet.DataStructures.Geometry; +using FluentAssertions; +using NUnit.Framework; + +namespace BlazorDatasheet.Test.SheetTests; + +public class RangeSortingTests +{ + [Test] + public void Sort_Range_Values_Only_Sorts() + { + var sheet = new Sheet(10, 10); + for (int row = 0; row < sheet.NumRows; row++) + { + sheet.Cells[row, 0].Value = sheet.NumRows - row; + sheet.Cells[row, 1].Value = row; + } + + var region = new ColumnRegion(0, 1); + var options = new List + { + new ColumnSortOptions(0, true) + }; + + var sortRangeCommand = new SortRangeCommand(region, options); + sortRangeCommand.Execute(sheet); + + for (int row = 0; row < sheet.NumRows; row++) + { + sheet.Cells[row, 0].Value.Should().Be(row); + sheet.Cells[row, 1].Value.Should().Be(sheet.NumRows - row); + } + + sortRangeCommand.Undo(sheet); + for (int row = 0; row < sheet.NumRows; row++) + { + sheet.Cells[row, 0].Value.Should().Be(sheet.NumRows - row); + sheet.Cells[row, 1].Value.Should().Be(row); + } + } + + // test that a sort on a single column with some empty cells works + [Test] + public void Sort_Col_With_Empty_Rows_Results_In_Continuous_Rows() + { + var sheet = new Sheet(5, 1); + sheet.Cells[0, 0].Value = 5; + sheet.Cells[1, 0].Value = 4; + sheet.Cells[3, 0].Value = 3; + + var region = new ColumnRegion(0, 0); + var options = new List + { + new ColumnSortOptions(0, true) + }; + + var cmd = new SortRangeCommand(region, options); + cmd.Execute(sheet); + + sheet.Cells[0, 0].Value.Should().Be(3); + sheet.Cells[1, 0].Value.Should().Be(4); + sheet.Cells[2, 0].Value.Should().Be(5); + sheet.Cells[3, 0].Value.Should().BeNull(); + + cmd.Undo(sheet); + sheet.Cells[0, 0].Value.Should().Be(5); + sheet.Cells[1, 0].Value.Should().Be(4); + sheet.Cells[2, 0].Value.Should().BeNull(); + sheet.Cells[3, 0].Value.Should().Be(3); + } + + [Test] + public void Sort_Empty_Sheet_Does_Not_Throw_Exception() + { + var sheet = new Sheet(10, 10); + + var region = new ColumnRegion(0, 0); + var options = new List + { + new ColumnSortOptions(0, true) + }; + + var sortRangeCommand = new SortRangeCommand(region, options); + + Action act = () => sortRangeCommand.Execute(sheet); + act.Should().NotThrow(); + } +} \ No newline at end of file From 76a376c2853e9b7d2d7aaafe18072ed0f89e2256 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Tue, 14 May 2024 19:57:27 +1000 Subject: [PATCH 05/26] Rename sparse stores --- .../Data/Cells/CellStore.Formula.cs | 4 +-- .../Data/Cells/CellStore.Validation.cs | 2 +- .../Data/Cells/CellStore.cs | 2 +- .../BlazorDatasheet.DataStructures.csproj | 6 ++++ .../Store/IMatrixDataStore.cs | 2 +- ...rixStore.cs => SparseMatrixStoreByCols.cs} | 4 +-- ...ixStore2.cs => SparseMatrixStoreByRows.cs} | 34 +++++++++++++++---- .../Store/DataStoreByColsTests.cs | 4 +-- .../Store/DataStoreByRowsTests.cs | 29 +++++++++++++++- .../Store/SparseListTests.cs | 6 ++-- 10 files changed, 74 insertions(+), 19 deletions(-) rename src/BlazorDatasheet.DataStructures/Store/{SparseMatrixStore.cs => SparseMatrixStoreByCols.cs} (98%) rename src/BlazorDatasheet.DataStructures/Store/{SparseMatrixStore2.cs => SparseMatrixStoreByRows.cs} (85%) diff --git a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs index d363a37a..b4a46dbc 100644 --- a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs +++ b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs @@ -11,7 +11,7 @@ public partial class CellStore /// /// Cell FORMULA /// - private readonly IMatrixDataStore _formulaStore = new SparseMatrixStore(); + private readonly IMatrixDataStore _formulaStore = new SparseMatrixStoreByRows(); /// /// Set the formula string for a row and col, and calculate the sheet. @@ -72,7 +72,7 @@ private CellStoreRestoreData CopyFormula(IRegion fromRegion, IRegion toRegion) foreach (var formula in formulaToCopy) { - if(formula.data == null) + if (formula.data == null) continue; var clonedFormula = formula.data.Clone(); clonedFormula.ShiftReferences(offset.row, offset.col); diff --git a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Validation.cs b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Validation.cs index 9091837a..6bbf6af8 100644 --- a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Validation.cs +++ b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Validation.cs @@ -10,7 +10,7 @@ public partial class CellStore /// /// Stores whether cells are valid. /// - private readonly IMatrixDataStore _validStore = new SparseMatrixStore(); + private readonly IMatrixDataStore _validStore = new SparseMatrixStoreByCols(); internal void ValidateRegion(IRegion region) { diff --git a/src/BlazorDatasheet.Core/Data/Cells/CellStore.cs b/src/BlazorDatasheet.Core/Data/Cells/CellStore.cs index c22b248f..adf76789 100644 --- a/src/BlazorDatasheet.Core/Data/Cells/CellStore.cs +++ b/src/BlazorDatasheet.Core/Data/Cells/CellStore.cs @@ -17,7 +17,7 @@ public partial class CellStore public CellStore(Sheet sheet) { _sheet = sheet; - _dataStore = new SparseMatrixStore2(_defaultCellValue); + _dataStore = new SparseMatrixStoreByRows(_defaultCellValue); } /// diff --git a/src/BlazorDatasheet.DataStructures/BlazorDatasheet.DataStructures.csproj b/src/BlazorDatasheet.DataStructures/BlazorDatasheet.DataStructures.csproj index f4a63524..0702dae4 100644 --- a/src/BlazorDatasheet.DataStructures/BlazorDatasheet.DataStructures.csproj +++ b/src/BlazorDatasheet.DataStructures/BlazorDatasheet.DataStructures.csproj @@ -7,4 +7,10 @@ 0.2.0 + + + <_Parameter1>BlazorDatasheet.Test + + + diff --git a/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs b/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs index 1f672876..f4f22712 100644 --- a/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs +++ b/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs @@ -6,7 +6,7 @@ namespace BlazorDatasheet.DataStructures.Store; public interface IMatrixDataStore { /// - /// Returns whether the the store contains any data at the row, column specified. + /// Returns whether the the store contains any non-empty data at the row, column specified. /// /// /// diff --git a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStore.cs b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs similarity index 98% rename from src/BlazorDatasheet.DataStructures/Store/SparseMatrixStore.cs rename to src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs index b6b81764..d9e4b2ef 100644 --- a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStore.cs +++ b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs @@ -3,7 +3,7 @@ namespace BlazorDatasheet.DataStructures.Store; -public class SparseMatrixStore : IMatrixDataStore +public class SparseMatrixStoreByCols : IMatrixDataStore { private readonly T _defaultValueIfEmpty; @@ -12,7 +12,7 @@ public class SparseMatrixStore : IMatrixDataStore /// private readonly Dictionary> _columns = new(); - public SparseMatrixStore(T defaultValueIfEmpty = default(T)) + public SparseMatrixStoreByCols(T defaultValueIfEmpty = default(T)) { _defaultValueIfEmpty = defaultValueIfEmpty; } diff --git a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStore2.cs b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs similarity index 85% rename from src/BlazorDatasheet.DataStructures/Store/SparseMatrixStore2.cs rename to src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs index 4f4e9ed9..b5ec88ca 100644 --- a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStore2.cs +++ b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs @@ -2,20 +2,20 @@ namespace BlazorDatasheet.DataStructures.Store; -public class SparseMatrixStore2 : IMatrixDataStore +public class SparseMatrixStoreByRows : IMatrixDataStore { private SparseList> _rows; private readonly T? _defaultIfEmpty; private readonly SparseList _emptyRow; - public SparseMatrixStore2(T defaultIfEmpty) + public SparseMatrixStoreByRows(T defaultIfEmpty) { _defaultIfEmpty = defaultIfEmpty; _emptyRow = new SparseList(defaultIfEmpty); _rows = new SparseList>(_emptyRow); } - public SparseMatrixStore2() : this(default(T)) + public SparseMatrixStoreByRows() : this(default(T)) { } @@ -134,12 +134,12 @@ public MatrixRestoreData RemoveColAt(int col, int nCols) public int GetNextNonBlankRow(int row, int col) { - var rowIndex = _rows.GetNextNonEmptyItemIndex(row + 1); + var rowIndex = _rows.GetNextNonEmptyItemKey(row + 1); while (rowIndex != -1) { if (_rows.Get(rowIndex).ContainsIndex(col)) return rowIndex; - rowIndex = _rows.GetNextNonEmptyItemIndex(rowIndex + 1); + rowIndex = _rows.GetNextNonEmptyItemKey(rowIndex + 1); } return -1; @@ -150,7 +150,7 @@ public int GetNextNonBlankColumn(int row, int col) if (!_rows.ContainsIndex(row)) return -1; - return _rows.Get(row).GetNextNonEmptyItemIndex(col + 1); + return _rows.Get(row).GetNextNonEmptyItemKey(col + 1); } public MatrixRestoreData RemoveRowAt(int row, int nRows) @@ -228,4 +228,26 @@ public T[][] GetData(IRegion region) return res; } + + public IMatrixDataStore GetSubMatrix(IRegion region, bool newStoreResetsOffsets = true) + { + var store = new SparseMatrixStoreByRows(_defaultIfEmpty); + int r0 = region.Top; + int r1 = region.Bottom; + int c0 = region.Left; + int c1 = region.Right; + + int rowOffset = newStoreResetsOffsets ? r0 : 0; + int colOffset = newStoreResetsOffsets ? c0 : 0; + + var nonEmptyRows = _rows.GetNonEmptyDataBetween(r0, r1); + foreach (var row in nonEmptyRows) + { + var data = row.data.GetNonEmptyDataBetween(c0, c1); + foreach (var col in data) + store.Set(row.itemIndex - rowOffset, col.itemIndex - colOffset, col.data); + } + + return store; + } } \ No newline at end of file diff --git a/test/BlazorDatasheet.Test/Store/DataStoreByColsTests.cs b/test/BlazorDatasheet.Test/Store/DataStoreByColsTests.cs index 50ff2423..50e280a1 100644 --- a/test/BlazorDatasheet.Test/Store/DataStoreByColsTests.cs +++ b/test/BlazorDatasheet.Test/Store/DataStoreByColsTests.cs @@ -8,9 +8,9 @@ namespace BlazorDatasheet.Test.Store; public class DataStoreByColsTests { - private SparseMatrixStore GetStore() + private SparseMatrixStoreByCols GetStore() { - return new SparseMatrixStore(); + return new SparseMatrixStoreByCols(); } [Test] diff --git a/test/BlazorDatasheet.Test/Store/DataStoreByRowsTests.cs b/test/BlazorDatasheet.Test/Store/DataStoreByRowsTests.cs index 350ac527..0c5a097a 100644 --- a/test/BlazorDatasheet.Test/Store/DataStoreByRowsTests.cs +++ b/test/BlazorDatasheet.Test/Store/DataStoreByRowsTests.cs @@ -10,7 +10,7 @@ public class DataStoreByRowsTests { private IMatrixDataStore GetStore() { - return new SparseMatrixStore2(); + return new SparseMatrixStoreByRows(); } [Test] @@ -209,4 +209,31 @@ public void Remove_Region_Removes_Region() store.Get(1, 1).Should().BeNullOrEmpty(); store.Get(2, 2).Should().Be("2,2"); } + + [Test] + public void Sub_Matrix_Tests() + { + var store = (SparseMatrixStoreByRows)GetStore(); + for (int row = 0; row < 10; row++) + { + for (int col = 0; col < 10; col++) + { + store.Set(row, col, $"{row},{col}"); + } + } + + var rowLen = 4; + var r0 = 2; + var colLen = 5; + var c0 = 3; + var subMatrix = store.GetSubMatrix(new Region(r0, r0 + rowLen - 1, c0, c0 + colLen - 1), + newStoreResetsOffsets: true); + for (int row = 0; row < rowLen; row++) + { + for (int col = 0; col < colLen; col++) + { + subMatrix.Get(row, col).Should().Be($"{row + r0},{col + c0}"); + } + } + } } \ No newline at end of file diff --git a/test/BlazorDatasheet.Test/Store/SparseListTests.cs b/test/BlazorDatasheet.Test/Store/SparseListTests.cs index 7ec3d31e..a229e976 100644 --- a/test/BlazorDatasheet.Test/Store/SparseListTests.cs +++ b/test/BlazorDatasheet.Test/Store/SparseListTests.cs @@ -72,8 +72,8 @@ public void Get_Next_Non_Empty_Index_Returns_Non_Empty() { _list.Set(10, 2); _list.Set(15, 3); - _list.GetNextNonEmptyItemIndex(0).Should().Be(10); - _list.GetNextNonEmptyItemIndex(11).Should().Be(15); - _list.GetNextNonEmptyItemIndex(11).Should().Be(15); + _list.GetNextNonEmptyItemKey(0).Should().Be(10); + _list.GetNextNonEmptyItemKey(11).Should().Be(15); + _list.GetNextNonEmptyItemKey(11).Should().Be(15); } } \ No newline at end of file From 6d00cf655eede4aaadb26dbd6dc8ea9aed245daf Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Tue, 14 May 2024 19:57:40 +1000 Subject: [PATCH 06/26] Tidy sparse list --- .../Store/SparseList.cs | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/BlazorDatasheet.DataStructures/Store/SparseList.cs b/src/BlazorDatasheet.DataStructures/Store/SparseList.cs index 803bd623..1fc81744 100644 --- a/src/BlazorDatasheet.DataStructures/Store/SparseList.cs +++ b/src/BlazorDatasheet.DataStructures/Store/SparseList.cs @@ -2,7 +2,7 @@ namespace BlazorDatasheet.DataStructures.Store; -public class SparseList +internal class SparseList { private readonly T _defaultValueIfEmpty; @@ -21,19 +21,14 @@ public bool ContainsIndex(int itemIndex) return Values.ContainsKey(itemIndex); } - public T Get(int itemIndex) + public T Get(int key) { - if (Values.TryGetValue(itemIndex, out var value)) - return value; - return _defaultValueIfEmpty; + return Values.GetValueOrDefault(key, _defaultValueIfEmpty); } public void Set(int itemIndex, T value) { - if (!Values.ContainsKey(itemIndex)) - Values.Add(itemIndex, value); - else - Values[itemIndex] = value; + Values[itemIndex] = value; } /// @@ -113,9 +108,10 @@ public List GotNonEmptyItemIndicesBetween(int i0, int i1) /// public List<(int itemIndex, T data)> GetNonEmptyDataBetween(int r0, int r1) { - var items = new List<(int itemIndex, T data)>(); if (!Values.Any()) - return items; + return new List<(int itemIndex, T data)>(); + + var items = new List<(int itemIndex, T data)>(); var indexStart = Values.Keys.BinarySearchClosest(r0); var index = indexStart; @@ -190,14 +186,14 @@ public List GotNonEmptyItemIndicesBetween(int i0, int i1) /// /// Returns the next non-empty item index after & including the index given. If none found, returns -1 /// - /// + /// /// - public int GetNextNonEmptyItemIndex(int itemIndex) + public int GetNextNonEmptyItemKey(int key) { if (!Values.Any()) return -1; - var index = Values.Keys.BinarySearchIndexOf(itemIndex, Comparer.Default); + var index = Values.Keys.BinarySearchIndexOf(key, Comparer.Default); if (index < 0) index = ~index; @@ -206,13 +202,33 @@ public int GetNextNonEmptyItemIndex(int itemIndex) return Values.Keys[index]; } + + /// + /// Returns the next non-empty value pair after & including the index given. If none found, returns null + /// + /// + /// + public (int Key, T Value)? GetNextNonEmptyItemValuePair(int key) + { + if (!Values.Any()) + return null; + + var index = Values.Keys.BinarySearchIndexOf(key, Comparer.Default); + if (index < 0) + index = ~index; + + if (index >= Values.Keys.Count) + return null; + + return (Values.Keys[index], Values.Values[index]); + } /// /// Removes all items in list, between (and including) r0 and r1. Does not affect those around it. /// /// /// - /// + /// The cleared items public List<(int itemIndexCleared, T)> ClearBetween(int r0, int r1) { var cleared = new List<(int itemIndexCleared, T val)>(); @@ -252,4 +268,6 @@ public T[] GetDataBetween(int i0, int i1) res[p.itemIndex - i0] = p.data; return res; } + + } \ No newline at end of file From e3742c3d03b387e0fe66bbd0798d2eef6378e9d3 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Tue, 14 May 2024 19:57:47 +1000 Subject: [PATCH 07/26] Add benchmarks --- src/Benchmarks/Benchmark/Benchmark.csproj | 18 ++++++ src/Benchmarks/Benchmark/Program.cs | 73 +++++++++++++++++++++++ src/BlazorDatasheet.sln | 7 +++ 3 files changed, 98 insertions(+) create mode 100644 src/Benchmarks/Benchmark/Benchmark.csproj create mode 100644 src/Benchmarks/Benchmark/Program.cs diff --git a/src/Benchmarks/Benchmark/Benchmark.csproj b/src/Benchmarks/Benchmark/Benchmark.csproj new file mode 100644 index 00000000..b31a2b32 --- /dev/null +++ b/src/Benchmarks/Benchmark/Benchmark.csproj @@ -0,0 +1,18 @@ + + + + Exe + net7.0 + enable + enable + + + + + + + + + + + diff --git a/src/Benchmarks/Benchmark/Program.cs b/src/Benchmarks/Benchmark/Program.cs new file mode 100644 index 00000000..76af2867 --- /dev/null +++ b/src/Benchmarks/Benchmark/Program.cs @@ -0,0 +1,73 @@ +//benchmark sparselist + +using System; +using System.Collections.Generic; +using System.Linq; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Running; +using BlazorDatasheet.DataStructures.Store; + +var benchmark = BenchmarkRunner.Run(); + +public class SparseMatrixBenchmark +{ + private IMatrixDataStore _sparseMatrixStoreByRows; + private IMatrixDataStore _sparseMatrixStoreByRow2; + private IMatrixDataStore _sparseMatrixStoreByCol; + + [GlobalSetup] + public void GlobalSetup() + { + _sparseMatrixStoreByRows = new SparseMatrixStoreByRows(); + _sparseMatrixStoreByCol = new SparseMatrixStoreByCols(); + Setup(_sparseMatrixStoreByRows); + Setup(_sparseMatrixStoreByRow2); + Setup(_sparseMatrixStoreByCol); + } + + private void Setup(IMatrixDataStore store) + { + var r = new Random(1); + for (var i = 0; i < 1000; i++) + { + store.Set(r.Next(0, 1000), r.Next(0, 1000), "A"); + } + } + + [Benchmark] + public void Get() + { + _sparseMatrixStoreByRows.Get(100, 100); + } + + [Benchmark] + public void Get2() + { + _sparseMatrixStoreByRow2.Get(100, 100); + } + + [Benchmark] + public void Get3() + { + _sparseMatrixStoreByCol.Get(100, 100); + } + + [Benchmark] + public void Set() + { + _sparseMatrixStoreByRows.Set(100, 100 , "B"); + } + + [Benchmark] + public void Set2() + { + _sparseMatrixStoreByRow2.Set(100, 100, "B"); + } + + [Benchmark] + public void Set3() + { + _sparseMatrixStoreByCol.Set(100, 100, "B"); + } +} \ No newline at end of file diff --git a/src/BlazorDatasheet.sln b/src/BlazorDatasheet.sln index 4b702607..4a396e66 100644 --- a/src/BlazorDatasheet.sln +++ b/src/BlazorDatasheet.sln @@ -20,6 +20,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorDatasheet.Core", "Bla EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorDatasheet.Formula.Functions", "BlazorDatasheet.Formula.Functions\BlazorDatasheet.Formula.Functions.csproj", "{75314586-24CB-4A30-8166-41544ADECEB8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmark", "Benchmarks\Benchmark\Benchmark.csproj", "{A16C6735-6BFB-4484-AAEF-AEAEDA5B068D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -62,8 +64,13 @@ Global {75314586-24CB-4A30-8166-41544ADECEB8}.Debug|Any CPU.Build.0 = Debug|Any CPU {75314586-24CB-4A30-8166-41544ADECEB8}.Release|Any CPU.ActiveCfg = Release|Any CPU {75314586-24CB-4A30-8166-41544ADECEB8}.Release|Any CPU.Build.0 = Release|Any CPU + {A16C6735-6BFB-4484-AAEF-AEAEDA5B068D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A16C6735-6BFB-4484-AAEF-AEAEDA5B068D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A16C6735-6BFB-4484-AAEF-AEAEDA5B068D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A16C6735-6BFB-4484-AAEF-AEAEDA5B068D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8D866B46-349C-4B1C-B5A6-146DAECCA089} = {88903F7E-79AF-45FD-A777-4E0A101AB65C} + {A16C6735-6BFB-4484-AAEF-AEAEDA5B068D} = {88903F7E-79AF-45FD-A777-4E0A101AB65C} EndGlobalSection EndGlobal From 0fd4029e5226e28bad30d440d887ae68ed384ac5 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sat, 18 May 2024 14:39:27 +1000 Subject: [PATCH 08/26] Add non-empty row collection methods to SparseMatrixStoreByRows.cs - currently not implemented in SparseMatrixStoreByCols.cs --- .../Store/IMatrixDataStore.cs | 8 +++++++ .../Store/RowData.cs | 22 +++++++++++++++++++ .../Store/RowDataCollection.cs | 13 +++++++++++ .../Store/SparseMatrixStoreByCols.cs | 5 +++++ .../Store/SparseMatrixStoreByRows.cs | 19 ++++++++++++++++ 5 files changed, 67 insertions(+) create mode 100644 src/BlazorDatasheet.DataStructures/Store/RowData.cs create mode 100644 src/BlazorDatasheet.DataStructures/Store/RowDataCollection.cs diff --git a/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs b/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs index f4f22712..94604da0 100644 --- a/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs +++ b/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs @@ -112,6 +112,14 @@ public interface IMatrixDataStore IEnumerable GetNonEmptyPositions(IRegion region) => GetNonEmptyPositions(region.Top, region.Bottom, region.Left, region.Right); + /// + /// Returns the collection of non-empty rows in the region given. + /// Data in the rows is limited to within the region start and end positions + /// + /// + /// + RowDataCollection GetNonEmptyRowData(IRegion region); + /// /// Copy the data in to the position /// diff --git a/src/BlazorDatasheet.DataStructures/Store/RowData.cs b/src/BlazorDatasheet.DataStructures/Store/RowData.cs new file mode 100644 index 00000000..74a6562f --- /dev/null +++ b/src/BlazorDatasheet.DataStructures/Store/RowData.cs @@ -0,0 +1,22 @@ +namespace BlazorDatasheet.DataStructures.Store; + +public class RowData +{ + private int[] ColumnIndices { get; set; } + private T[] Values { get; set; } + + public RowData(int[] columnIndices, T[] values) + { + ColumnIndices = columnIndices; + Values = values; + } + + public T? GetColumnData(int columnIndex) + { + var index = Array.IndexOf(ColumnIndices, columnIndex); + if (index < 0) + return default(T); + + return Values[index]; + } +} \ No newline at end of file diff --git a/src/BlazorDatasheet.DataStructures/Store/RowDataCollection.cs b/src/BlazorDatasheet.DataStructures/Store/RowDataCollection.cs new file mode 100644 index 00000000..1b2e692f --- /dev/null +++ b/src/BlazorDatasheet.DataStructures/Store/RowDataCollection.cs @@ -0,0 +1,13 @@ +namespace BlazorDatasheet.DataStructures.Store; + +public class RowDataCollection +{ + public int[] RowIndicies { get; private set; } + public RowData[] Rows { get; private set; } + + internal RowDataCollection(int[] rowIndicies, RowData[] rows) + { + RowIndicies = rowIndicies; + Rows = rows; + } +} \ No newline at end of file diff --git a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs index d9e4b2ef..f382544c 100644 --- a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs +++ b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs @@ -276,6 +276,11 @@ public IEnumerable GetNonEmptyPositions(IRegion region) return GetNonEmptyPositions(region.Top, region.Bottom, region.Left, region.Right); } + public RowDataCollection GetNonEmptyRowData(IRegion region) + { + throw new NotImplementedException(); + } + public MatrixRestoreData Copy(IRegion fromRegion, IRegion toRegion) { var nonEmptyCopyData = this.GetNonEmptyData(fromRegion).ToList(); diff --git a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs index b5ec88ca..b5169450 100644 --- a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs +++ b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs @@ -250,4 +250,23 @@ public IMatrixDataStore GetSubMatrix(IRegion region, bool newStoreResetsOffse return store; } + + public RowDataCollection GetNonEmptyRowData(IRegion region) + { + var nonEmptyRows = _rows.GetNonEmptyDataBetween(region.Top, region.Bottom); + var rowIndices = new int[nonEmptyRows.Count]; + var rowDataArray = new RowData[nonEmptyRows.Count]; + for (int i = 0; i < nonEmptyRows.Count; i++) + { + var row = nonEmptyRows[i]; + rowIndices[i] = row.itemIndex; + var nonEmptyCols = row.data.GetNonEmptyDataBetween(region.Left, region.Right); + var colIndices = nonEmptyCols.Select(x => x.itemIndex).ToArray(); + var colData = nonEmptyCols.Select(x => x.data).ToArray(); + var rowData = new RowData(colIndices, colData); + rowDataArray[i] = rowData; + } + + return new RowDataCollection(rowIndices, rowDataArray); + } } \ No newline at end of file From 2e2f9fb19d63903fab0d1a8f9b0818bc002d41ec Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sat, 18 May 2024 15:37:04 +1000 Subject: [PATCH 09/26] Update sort range command to work on values only --- .../Commands/SortRangeCommand.cs | 80 ++++++++++++++++++- .../Data/Cells/CellStore.Data.cs | 6 +- .../FormulaEngine/Environment.cs | 2 +- .../Store/IMatrixDataStore.cs | 8 ++ .../Store/RowData.cs | 8 +- .../Store/SparseMatrixStoreByCols.cs | 5 ++ .../Store/SparseMatrixStoreByRows.cs | 20 ++++- .../SortRangeCommandTests.cs} | 10 +-- 8 files changed, 126 insertions(+), 13 deletions(-) rename test/BlazorDatasheet.Test/{SheetTests/RangeSortingTests.cs => Commands/SortRangeCommandTests.cs} (93%) diff --git a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs index 3aa319c8..c8ed4b47 100644 --- a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs +++ b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs @@ -1,12 +1,16 @@ using BlazorDatasheet.Core.Data; using BlazorDatasheet.DataStructures.Geometry; +using BlazorDatasheet.DataStructures.Store; +using BlazorDatasheet.Formula.Core; namespace BlazorDatasheet.Core.Commands; public class SortRangeCommand : IUndoableCommand { - private IRegion _region; - private List? _sortOptions = new List(); + private readonly IRegion _region; + private IRegion? _sortedRegion; + private readonly List _sortOptions; + private int[] oldIndices = Array.Empty(); /// /// Sorts the specified region on values using the specified sort options. @@ -22,11 +26,83 @@ public SortRangeCommand(IRegion region, List? sortOptions = n public bool Execute(Sheet sheet) { + var store = sheet.Cells.GetCellDataStore(); + var rowCollection = store.GetNonEmptyRowData(_region); + var rowIndices = new Span(rowCollection.RowIndicies); + var rowData = new Span>(rowCollection.Rows); + rowData.Sort(rowIndices, Comparison); + + sheet.BatchUpdates(); + + for (int i = 0; i < rowData.Length; i++) + { + var newRowNo = _region.Top + i; + for (int j = 0; j < rowData[i].ColumnIndices.Length; j++) + { + var col = rowData[i].ColumnIndices[j]; + var val = rowData[i].Values[j]; + sheet.Cells.SetValue(newRowNo, col, val); + } + } + + sheet.EndBatchUpdates(); + + oldIndices = rowIndices.ToArray(); + _sortedRegion = new Region(_region.Top, _region.Top + oldIndices.Length, _region.Left, _region.Right); + _sortedRegion = _sortedRegion.GetIntersection(sheet.Region); + return true; } + private int Comparison(RowData x, RowData y) + { + for (int i = 0; i < _sortOptions.Count; i++) + { + var sortOption = _sortOptions[i]; + var xValue = x.GetColumnData(sortOption.ColumnIndex); + var yValue = y.GetColumnData(sortOption.ColumnIndex); + + int comparison = 0; + if (xValue != null && yValue != null) + comparison = xValue.CompareTo(yValue); + else + comparison = xValue == null ? -1 : 1; + + if (comparison != 0) + return sortOption.Ascending ? comparison : -comparison; + } + + return 0; + } + public bool Undo(Sheet sheet) { + if (_sortedRegion == null) + return true; + + var rowCollection = sheet.Cells.GetCellDataStore().GetRowData(_sortedRegion); + sheet.BatchUpdates(); + + sheet.Cells.GetCellDataStore().Clear(new Region( + _sortedRegion.Top, + _sortedRegion.Top + oldIndices.Length, + _sortedRegion.Left, + _sortedRegion.Right)); + + var rowData = rowCollection.Rows; + for (int i = 0; i < oldIndices.Length; i++) + { + var newRowNo = oldIndices[i] + _region.Top; + for (int j = 0; j < rowData[i].ColumnIndices.Length; j++) + { + var col = rowData[i].ColumnIndices[j]; + var val = rowData[i].Values[j]; + sheet.Cells.SetValue(newRowNo, col, val); + } + } + + sheet.EndBatchUpdates(); + return true; } } diff --git a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Data.cs b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Data.cs index 90023d2e..77ea3696 100644 --- a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Data.cs +++ b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Data.cs @@ -124,7 +124,11 @@ public CellValue GetCellValue(int row, int col) return _dataStore.Get(row, col)!; } - public IMatrixDataStore GetStore() + /// + /// Returns the sparse matrix store that holds the cell data. + /// + /// + public IMatrixDataStore GetCellDataStore() { return _dataStore; } diff --git a/src/BlazorDatasheet.Core/FormulaEngine/Environment.cs b/src/BlazorDatasheet.Core/FormulaEngine/Environment.cs index cc556129..69440d2b 100644 --- a/src/BlazorDatasheet.Core/FormulaEngine/Environment.cs +++ b/src/BlazorDatasheet.Core/FormulaEngine/Environment.cs @@ -89,6 +89,6 @@ private CellValue[][] GetValuesInRange(SheetRange range) var region = range.Region.GetIntersection(range.Sheet.Region); if (region == null) return Array.Empty(); - return range.Sheet.Cells.GetStore().GetData(region); + return range.Sheet.Cells.GetCellDataStore().GetData(region); } } \ No newline at end of file diff --git a/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs b/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs index 94604da0..8e622415 100644 --- a/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs +++ b/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs @@ -120,6 +120,14 @@ IEnumerable GetNonEmptyPositions(IRegion region) => /// RowDataCollection GetNonEmptyRowData(IRegion region); + /// + /// Returns the collection of rows in the region given. + /// Data in the rows is limited to within the region start and end positions + /// + /// + /// + RowDataCollection GetRowData(IRegion region); + /// /// Copy the data in to the position /// diff --git a/src/BlazorDatasheet.DataStructures/Store/RowData.cs b/src/BlazorDatasheet.DataStructures/Store/RowData.cs index 74a6562f..63254ef8 100644 --- a/src/BlazorDatasheet.DataStructures/Store/RowData.cs +++ b/src/BlazorDatasheet.DataStructures/Store/RowData.cs @@ -2,13 +2,15 @@ public class RowData { - private int[] ColumnIndices { get; set; } - private T[] Values { get; set; } + public int Row { get; private set; } + public int[] ColumnIndices { get; set; } + public T[] Values { get; set; } - public RowData(int[] columnIndices, T[] values) + public RowData(int row, int[] columnIndices, T[] values) { ColumnIndices = columnIndices; Values = values; + Row = row; } public T? GetColumnData(int columnIndex) diff --git a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs index f382544c..6cce8d11 100644 --- a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs +++ b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs @@ -281,6 +281,11 @@ public RowDataCollection GetNonEmptyRowData(IRegion region) throw new NotImplementedException(); } + public RowDataCollection GetRowData(IRegion region) + { + throw new NotImplementedException(); + } + public MatrixRestoreData Copy(IRegion fromRegion, IRegion toRegion) { var nonEmptyCopyData = this.GetNonEmptyData(fromRegion).ToList(); diff --git a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs index b5169450..c390abe9 100644 --- a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs +++ b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs @@ -263,10 +263,28 @@ public RowDataCollection GetNonEmptyRowData(IRegion region) var nonEmptyCols = row.data.GetNonEmptyDataBetween(region.Left, region.Right); var colIndices = nonEmptyCols.Select(x => x.itemIndex).ToArray(); var colData = nonEmptyCols.Select(x => x.data).ToArray(); - var rowData = new RowData(colIndices, colData); + var rowData = new RowData(rowIndices[i], colIndices, colData); rowDataArray[i] = rowData; } return new RowDataCollection(rowIndices, rowDataArray); } + + public RowDataCollection GetRowData(IRegion region) + { + var indices = new int[region.Height]; + var rows = new RowData[region.Height]; + + for (int i = 0; i < region.Height; i++) + { + var row = _rows.Get(i + region.Top); + var nonEmptyCols = row.GetNonEmptyDataBetween(region.Left, region.Right); + var colIndices = nonEmptyCols.Select(x => x.itemIndex).ToArray(); + var colData = nonEmptyCols.Select(x => x.data).ToArray(); + indices[i] = i + region.Top; + rows[i] = new RowData(i + region.Top, colIndices, colData); + } + + return new RowDataCollection(indices, rows); + } } \ No newline at end of file diff --git a/test/BlazorDatasheet.Test/SheetTests/RangeSortingTests.cs b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs similarity index 93% rename from test/BlazorDatasheet.Test/SheetTests/RangeSortingTests.cs rename to test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs index c800c225..c083fd18 100644 --- a/test/BlazorDatasheet.Test/SheetTests/RangeSortingTests.cs +++ b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs @@ -6,9 +6,9 @@ using FluentAssertions; using NUnit.Framework; -namespace BlazorDatasheet.Test.SheetTests; +namespace BlazorDatasheet.Test.Commands; -public class RangeSortingTests +public class SortRangeCommandTests { [Test] public void Sort_Range_Values_Only_Sorts() @@ -16,7 +16,7 @@ public void Sort_Range_Values_Only_Sorts() var sheet = new Sheet(10, 10); for (int row = 0; row < sheet.NumRows; row++) { - sheet.Cells[row, 0].Value = sheet.NumRows - row; + sheet.Cells[row, 0].Value = sheet.NumRows - row - 1; sheet.Cells[row, 1].Value = row; } @@ -32,13 +32,13 @@ public void Sort_Range_Values_Only_Sorts() for (int row = 0; row < sheet.NumRows; row++) { sheet.Cells[row, 0].Value.Should().Be(row); - sheet.Cells[row, 1].Value.Should().Be(sheet.NumRows - row); + sheet.Cells[row, 1].Value.Should().Be(sheet.NumRows - row - 1); } sortRangeCommand.Undo(sheet); for (int row = 0; row < sheet.NumRows; row++) { - sheet.Cells[row, 0].Value.Should().Be(sheet.NumRows - row); + sheet.Cells[row, 0].Value.Should().Be(sheet.NumRows - row - 1); sheet.Cells[row, 1].Value.Should().Be(row); } } From 29fe9ffb9b6d7e42f03814ffcfbf6d6ec30b9430 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sat, 18 May 2024 19:35:45 +1000 Subject: [PATCH 10/26] Fix null comparisons in sort. --- .../Commands/SortRangeCommand.cs | 51 ++++++++++++------- .../Commands/SortRangeCommandTests.cs | 2 + 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs index c8ed4b47..eb1243cd 100644 --- a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs +++ b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs @@ -32,24 +32,38 @@ public bool Execute(Sheet sheet) var rowData = new Span>(rowCollection.Rows); rowData.Sort(rowIndices, Comparison); + _sortedRegion = new Region(_region.Top, _region.Top + rowIndices.Length, _region.Left, _region.Right); + _sortedRegion = _sortedRegion.GetIntersection(sheet.Region); + + if (_sortedRegion == null) + return true; + sheet.BatchUpdates(); + // clear any row data that has been shifted + for (int i = 0; i < rowData.Length; i++) + { + var row = rowData[i].Row; + var rowReg = new Region(row, row, _region.Left, _region.Right); + sheet.Cells.ClearCellsImpl(new[] { rowReg }); + } + for (int i = 0; i < rowData.Length; i++) { var newRowNo = _region.Top + i; + var oldRowNo = rowData[i].Row; + for (int j = 0; j < rowData[i].ColumnIndices.Length; j++) { var col = rowData[i].ColumnIndices[j]; var val = rowData[i].Values[j]; - sheet.Cells.SetValue(newRowNo, col, val); + sheet.Cells.SetValueImpl(newRowNo, col, val); } } sheet.EndBatchUpdates(); oldIndices = rowIndices.ToArray(); - _sortedRegion = new Region(_region.Top, _region.Top + oldIndices.Length, _region.Left, _region.Right); - _sortedRegion = _sortedRegion.GetIntersection(sheet.Region); return true; } @@ -59,17 +73,24 @@ private int Comparison(RowData x, RowData y) for (int i = 0; i < _sortOptions.Count; i++) { var sortOption = _sortOptions[i]; - var xValue = x.GetColumnData(sortOption.ColumnIndex); - var yValue = y.GetColumnData(sortOption.ColumnIndex); + var xValue = x.GetColumnData(sortOption.ColumnIndex + _region.Left); + var yValue = y.GetColumnData(sortOption.ColumnIndex + _region.Left); + + int comparison; - int comparison = 0; - if (xValue != null && yValue != null) + if(xValue == null && yValue == null) + comparison = 0; + else if (xValue != null && yValue != null) comparison = xValue.CompareTo(yValue); else - comparison = xValue == null ? -1 : 1; + comparison = xValue == null ? 1 : -1; + + if (comparison == 0) + return comparison; - if (comparison != 0) - return sortOption.Ascending ? comparison : -comparison; + comparison = sortOption.Ascending ? comparison : -comparison; + + return comparison; } return 0; @@ -83,21 +104,17 @@ public bool Undo(Sheet sheet) var rowCollection = sheet.Cells.GetCellDataStore().GetRowData(_sortedRegion); sheet.BatchUpdates(); - sheet.Cells.GetCellDataStore().Clear(new Region( - _sortedRegion.Top, - _sortedRegion.Top + oldIndices.Length, - _sortedRegion.Left, - _sortedRegion.Right)); + sheet.Cells.ClearCellsImpl(new[] { _sortedRegion }); var rowData = rowCollection.Rows; for (int i = 0; i < oldIndices.Length; i++) { - var newRowNo = oldIndices[i] + _region.Top; + var newRowNo = oldIndices[i]; for (int j = 0; j < rowData[i].ColumnIndices.Length; j++) { var col = rowData[i].ColumnIndices[j]; var val = rowData[i].Values[j]; - sheet.Cells.SetValue(newRowNo, col, val); + sheet.Cells.SetValueImpl(newRowNo, col, val); } } diff --git a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs index c083fd18..a26a46dc 100644 --- a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs +++ b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs @@ -16,7 +16,9 @@ public void Sort_Range_Values_Only_Sorts() var sheet = new Sheet(10, 10); for (int row = 0; row < sheet.NumRows; row++) { + // set first row to descending e.g 9, 8, 7, ... sheet.Cells[row, 0].Value = sheet.NumRows - row - 1; + // set second row to ascending e.g 0,1,2... sheet.Cells[row, 1].Value = row; } From b8edd3499aa453496f377228d3ba45c8f28aa343 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sat, 18 May 2024 20:17:15 +1000 Subject: [PATCH 11/26] Fix bug with references not clearing after new formula is set. --- .../Data/Cells/CellStore.Formula.cs | 4 +- .../FormulaEngine/FormulaEngine.cs | 23 +++------- .../Graph/DependencyGraph.cs | 16 +++++++ .../Interpreter/Parsing/Parser.cs | 3 +- .../Formula/SheetFormulaIntegrationTests.cs | 43 +++++++++++++++++++ 5 files changed, 68 insertions(+), 21 deletions(-) diff --git a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs index b4a46dbc..4bb24300 100644 --- a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs +++ b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs @@ -85,7 +85,7 @@ private CellStoreRestoreData CopyFormula(IRegion fromRegion, IRegion toRegion) internal CellStoreRestoreData ClearFormulaImpl(int row, int col) { var restoreData = _formulaStore.Clear(row, col); - _sheet.FormulaEngine.RemoveFromDependencyGraph(row, col); + _sheet.FormulaEngine.RemoveFormula(row, col); return new CellStoreRestoreData() { FormulaRestoreData = restoreData }; } @@ -94,7 +94,7 @@ internal CellStoreRestoreData ClearFormulaImpl(IEnumerable regions) var clearedData = _formulaStore.Clear(regions); foreach (var clearedFormula in clearedData.DataRemoved) { - _sheet.FormulaEngine.RemoveFromDependencyGraph(clearedFormula.row, clearedFormula.col); + _sheet.FormulaEngine.RemoveFormula(clearedFormula.row, clearedFormula.col); } return new CellStoreRestoreData() diff --git a/src/BlazorDatasheet.Core/FormulaEngine/FormulaEngine.cs b/src/BlazorDatasheet.Core/FormulaEngine/FormulaEngine.cs index 4f495905..23c0627b 100644 --- a/src/BlazorDatasheet.Core/FormulaEngine/FormulaEngine.cs +++ b/src/BlazorDatasheet.Core/FormulaEngine/FormulaEngine.cs @@ -172,30 +172,17 @@ public CellValue Evaluate(CellFormula? formula, bool resolveReferences = true) } /// - /// Deletes a formula if it exists. + /// Removes any vertices that the formula in this cell is dependent on /// /// /// - public void RemoveFromDependencyGraph(int row, int col) + public void RemoveFormula(int row, int col) { var vertex = new CellVertex(row, col); - var dependent = _dependencyGraph.Prec(vertex); - _dependencyGraph.RemoveVertex(vertex); - - foreach (var d in dependent) + var dependsOn = _dependencyGraph.Prec(vertex); + foreach (var dependent in dependsOn) { - if (!_dependencyGraph.IsDependedOn(d)) - { - _dependencyGraph.RemoveVertex(d); - } - - if (d is RegionVertex r) - { - var equalRegions = _observedRanges.GetDataRegions(r.Region) - .Where(x => x.Region.Equals(r.Region)).ToList(); - if (equalRegions.Any()) - _observedRanges.Delete(equalRegions.First()); - } + _dependencyGraph.RemoveEdge(dependent, vertex); } } diff --git a/src/BlazorDatasheet.DataStructures/Graph/DependencyGraph.cs b/src/BlazorDatasheet.DataStructures/Graph/DependencyGraph.cs index cfdae866..e44838cb 100644 --- a/src/BlazorDatasheet.DataStructures/Graph/DependencyGraph.cs +++ b/src/BlazorDatasheet.DataStructures/Graph/DependencyGraph.cs @@ -132,10 +132,21 @@ public void RemoveEdge(Vertex v, Vertex w) _adj[v.Key].Remove(w.Key); _prec[w.Key].Remove(v.Key); _numEdges--; + + RemoveIfNoDependents(v); + RemoveIfNoDependents(w); } } } + private void RemoveIfNoDependents(Vertex v) + { + if (!IsDependedOn(v) && !HasDependents(v)) + { + RemoveVertex(v); + } + } + /// /// Adds an edge between the two vertices. /// If the vertices are not already present, they are added @@ -164,6 +175,11 @@ public bool IsDependedOn(Vertex v) return Adj(v).Any(); } + public bool HasDependents(Vertex v) + { + return Prec(v).Any(); + } + /// /// Adds edges between the vertex v and all vertices in the array ws /// diff --git a/src/BlazorDatasheet.Formula.Core/Interpreter/Parsing/Parser.cs b/src/BlazorDatasheet.Formula.Core/Interpreter/Parsing/Parser.cs index 5c536578..beab66b7 100644 --- a/src/BlazorDatasheet.Formula.Core/Interpreter/Parsing/Parser.cs +++ b/src/BlazorDatasheet.Formula.Core/Interpreter/Parsing/Parser.cs @@ -11,7 +11,7 @@ public class Parser private int _position; private Token[] _tokens = null!; private List _errors = null!; - private readonly List _references = new(); + private List _references = new(); public SyntaxTree Parse(Token[] tokens, List? lexErrors = null) { @@ -29,6 +29,7 @@ public SyntaxTree Parse(string formulaString) { var lexer = new Lexer(); var tokens = lexer.Lex(formulaString); + _references = new(); return Parse(tokens, lexer.Errors); } diff --git a/test/BlazorDatasheet.Test/Formula/SheetFormulaIntegrationTests.cs b/test/BlazorDatasheet.Test/Formula/SheetFormulaIntegrationTests.cs index f3a95c67..b9e37bd9 100644 --- a/test/BlazorDatasheet.Test/Formula/SheetFormulaIntegrationTests.cs +++ b/test/BlazorDatasheet.Test/Formula/SheetFormulaIntegrationTests.cs @@ -147,4 +147,47 @@ public void Formula_Referencing_Range_With_Formula_Recalcs_When_Formula_Recalcs( _sheet.Cells.GetValue(2, 0).Should().Be((10 + 20) / 2d); _sheet.Cells.GetValue(3, 0).Should().Be((15 + 20) / 2d); } + + [Test] + public void Formula_Referencing_Deleted_Formula_Updates_When_Formula_Is_Cleared_Then_Value_Changes() + { + _sheet.Cells.SetFormula(0, 0, "=A2"); + _sheet.Cells.SetFormula(0, 1, "=A1"); + _sheet.Cells.ClearCells(new Region(0, 0)); + _sheet.Cells.SetValue(0, 0, 10); + _sheet.Cells.GetCellValue(0, 1).GetValue().Should().Be(10); + } + + [Test] + public void Formula_Referencing_Deleted_Formula_Updates_When_Formula_Has_Value_Set_Over_It() + { + _sheet.Cells.SetFormula(0, 0, "=A2"); + _sheet.Cells.SetFormula(0, 1, "=A1"); + + // now override the formula in A1, the formula in B1 (0,1) should update to the new value + _sheet.Cells.SetValue(0, 0, 10); + + _sheet.Cells.GetCellValue(0, 1).GetValue().Should().Be(10); + } + + [Test] + public void Sheet_Should_Not_Recalculate_If_Formula_Removed_From_Sheet() + { + _sheet.Cells[0, 0].Formula = "=B1"; // set A1 = B1 + _sheet.Cells[1, 0].Formula = "=A1"; // set A2 = A1 + // override formula at A1 - should remove links from A1 -> B1 + _sheet.Cells.SetValue(0, 0, string.Empty); + // set value at b1 and ensure sheet doesn't calculate + + int changeCount = 0; + + _sheet.Cells.CellsChanged += (sender, args) => + { + changeCount++; + }; + // change B1 + _sheet.Cells[0, 1].Value = 2; + + changeCount.Should().Be(1); + } } \ No newline at end of file From c0038994035abffa1b40a70857c72704f509f6ad Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sat, 18 May 2024 20:44:35 +1000 Subject: [PATCH 12/26] Add test for formula sorting --- .../Commands/SortRangeCommand.cs | 15 +++++++++++-- .../Commands/SortRangeCommandTests.cs | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs index eb1243cd..604f0aaf 100644 --- a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs +++ b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs @@ -24,6 +24,17 @@ public SortRangeCommand(IRegion region, List? sortOptions = n { new(0, true) }; } + /// + /// Sorts the specified region on values using the specified sort options. + /// + /// The region to sort + /// The column sort options, if null the default sort (sort on column 0 ascending) will be used. + public SortRangeCommand(IRegion region, ColumnSortOptions sortOption) + { + _region = region; + _sortOptions = new List { sortOption }; + } + public bool Execute(Sheet sheet) { var store = sheet.Cells.GetCellDataStore(); @@ -78,7 +89,7 @@ private int Comparison(RowData x, RowData y) int comparison; - if(xValue == null && yValue == null) + if (xValue == null && yValue == null) comparison = 0; else if (xValue != null && yValue != null) comparison = xValue.CompareTo(yValue); @@ -89,7 +100,7 @@ private int Comparison(RowData x, RowData y) return comparison; comparison = sortOption.Ascending ? comparison : -comparison; - + return comparison; } diff --git a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs index a26a46dc..38ec2993 100644 --- a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs +++ b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs @@ -75,6 +75,28 @@ public void Sort_Col_With_Empty_Rows_Results_In_Continuous_Rows() sheet.Cells[3, 0].Value.Should().Be(3); } + [Test] + public void Sort_Formula_Adjusts_References() + { + var sheet = new Sheet(10, 10); + sheet.Cells.SetFormula(0, 0, "=B1+3"); + sheet.Cells.SetFormula(1, 0, "=B2+2"); + sheet.Cells.SetFormula(2, 0, "=B3+1"); + + var cmd = new SortRangeCommand(new ColumnRegion(0), new ColumnSortOptions(0, true)); + cmd.Execute(sheet); + + sheet.Cells.GetFormulaString(0, 0).Should().Be("=B3+1"); + sheet.Cells.GetFormulaString(1, 0).Should().Be("=B2+2"); + sheet.Cells.GetFormulaString(2, 0).Should().Be("=B1+3"); + + cmd.Undo(sheet); + + sheet.Cells.GetFormulaString(0, 0).Should().Be("=B1+3"); + sheet.Cells.GetFormulaString(1, 0).Should().Be("=B2+2"); + sheet.Cells.GetFormulaString(2, 0).Should().Be("=B3+1"); + } + [Test] public void Sort_Empty_Sheet_Does_Not_Throw_Exception() { From cb5c8e8e9f89e4ee9fbbe210ddc093ad4c37f814 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sat, 18 May 2024 21:49:29 +1000 Subject: [PATCH 13/26] Move formula when sorting --- .../Commands/SortRangeCommand.cs | 28 ++++++++++++++++--- .../Data/Cells/CellStore.Formula.cs | 2 ++ .../Store/RowDataCollection.cs | 21 +++++++++++++- src/BlazorDatasheet.Formula.Core/CellValue.cs | 2 +- .../Commands/SortRangeCommandTests.cs | 18 ++++++------ .../Geometry/GraphTests.cs | 4 +-- 6 files changed, 58 insertions(+), 17 deletions(-) diff --git a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs index 604f0aaf..b4c493bc 100644 --- a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs +++ b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs @@ -51,7 +51,9 @@ public bool Execute(Sheet sheet) sheet.BatchUpdates(); - // clear any row data that has been shifted + var formulaData = sheet.Cells.GetFormulaStore().GetNonEmptyRowData(_region); + + // clear any row data that has been shifted (which should be all non-empty rows) for (int i = 0; i < rowData.Length; i++) { var row = rowData[i].Row; @@ -68,7 +70,14 @@ public bool Execute(Sheet sheet) { var col = rowData[i].ColumnIndices[j]; var val = rowData[i].Values[j]; - sheet.Cells.SetValueImpl(newRowNo, col, val); + var formula = formulaData.GetValue(oldRowNo, col); + if (formula == null) + sheet.Cells.SetValueImpl(newRowNo, col, val); + else + { + formula.ShiftReferences((newRowNo - oldRowNo), 0); + sheet.Cells.SetFormulaImpl(newRowNo, col, formula); + } } } @@ -113,19 +122,30 @@ public bool Undo(Sheet sheet) return true; var rowCollection = sheet.Cells.GetCellDataStore().GetRowData(_sortedRegion); + var formulaCollection = sheet.Cells.GetFormulaStore().GetRowData(_sortedRegion); sheet.BatchUpdates(); sheet.Cells.ClearCellsImpl(new[] { _sortedRegion }); var rowData = rowCollection.Rows; + var rowIndices = rowCollection.RowIndicies; for (int i = 0; i < oldIndices.Length; i++) { var newRowNo = oldIndices[i]; for (int j = 0; j < rowData[i].ColumnIndices.Length; j++) { var col = rowData[i].ColumnIndices[j]; - var val = rowData[i].Values[j]; - sheet.Cells.SetValueImpl(newRowNo, col, val); + var formula = formulaCollection.GetValue(rowIndices[i], col); + if (formula == null) + { + var val = rowData[i].Values[j]; + sheet.Cells.SetValueImpl(newRowNo, col, val); + } + else + { + formula.ShiftReferences((newRowNo - rowIndices[i]), 0); + sheet.Cells.SetFormulaImpl(newRowNo, col, formula); + } } } diff --git a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs index 4bb24300..5a642df4 100644 --- a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs +++ b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs @@ -134,4 +134,6 @@ public void SetFormula(int row, int col, CellFormula parsedFormula) { _sheet.Commands.ExecuteCommand(new SetParsedFormulaCommand(row, col, parsedFormula)); } + + public IMatrixDataStore GetFormulaStore() => _formulaStore; } \ No newline at end of file diff --git a/src/BlazorDatasheet.DataStructures/Store/RowDataCollection.cs b/src/BlazorDatasheet.DataStructures/Store/RowDataCollection.cs index 1b2e692f..3af8c751 100644 --- a/src/BlazorDatasheet.DataStructures/Store/RowDataCollection.cs +++ b/src/BlazorDatasheet.DataStructures/Store/RowDataCollection.cs @@ -1,4 +1,6 @@ -namespace BlazorDatasheet.DataStructures.Store; +using BlazorDatasheet.DataStructures.Search; + +namespace BlazorDatasheet.DataStructures.Store; public class RowDataCollection { @@ -10,4 +12,21 @@ internal RowDataCollection(int[] rowIndicies, RowData[] rows) RowIndicies = rowIndicies; Rows = rows; } + + public RowData? GetRow(int row) + { + var index = RowIndicies.BinarySearchIndexOf(row); + if (index < 0) + return null; + return Rows[index]; + } + + public T? GetValue(int row, int col) + { + var rowData = GetRow(row); + if (rowData == null) + return default(T); + + return rowData.GetColumnData(col); + } } \ No newline at end of file diff --git a/src/BlazorDatasheet.Formula.Core/CellValue.cs b/src/BlazorDatasheet.Formula.Core/CellValue.cs index 1f9d4019..07276ce2 100644 --- a/src/BlazorDatasheet.Formula.Core/CellValue.cs +++ b/src/BlazorDatasheet.Formula.Core/CellValue.cs @@ -249,7 +249,7 @@ public bool IsError() public int CompareTo(CellValue? other) { if (other == null) - return -1; + return 1; if (this.Data == null || other.Data == null) return -1; diff --git a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs index 38ec2993..d39da8a8 100644 --- a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs +++ b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs @@ -79,22 +79,22 @@ public void Sort_Col_With_Empty_Rows_Results_In_Continuous_Rows() public void Sort_Formula_Adjusts_References() { var sheet = new Sheet(10, 10); - sheet.Cells.SetFormula(0, 0, "=B1+3"); - sheet.Cells.SetFormula(1, 0, "=B2+2"); - sheet.Cells.SetFormula(2, 0, "=B3+1"); + sheet.Cells.SetFormula(0, 0, "=B4+3"); + sheet.Cells.SetFormula(1, 0, "=B6+2"); + sheet.Cells.SetFormula(2, 0, "=B7+1"); var cmd = new SortRangeCommand(new ColumnRegion(0), new ColumnSortOptions(0, true)); cmd.Execute(sheet); - sheet.Cells.GetFormulaString(0, 0).Should().Be("=B3+1"); - sheet.Cells.GetFormulaString(1, 0).Should().Be("=B2+2"); - sheet.Cells.GetFormulaString(2, 0).Should().Be("=B1+3"); + sheet.Cells.GetFormulaString(0, 0).Should().Be("=B5+1"); + sheet.Cells.GetFormulaString(1, 0).Should().Be("=B6+2"); + sheet.Cells.GetFormulaString(2, 0).Should().Be("=B6+3"); cmd.Undo(sheet); - sheet.Cells.GetFormulaString(0, 0).Should().Be("=B1+3"); - sheet.Cells.GetFormulaString(1, 0).Should().Be("=B2+2"); - sheet.Cells.GetFormulaString(2, 0).Should().Be("=B3+1"); + sheet.Cells.GetFormulaString(0, 0).Should().Be("=B4+3"); + sheet.Cells.GetFormulaString(1, 0).Should().Be("=B6+2"); + sheet.Cells.GetFormulaString(2, 0).Should().Be("=B7+1"); } [Test] diff --git a/test/BlazorDatasheet.Test/Geometry/GraphTests.cs b/test/BlazorDatasheet.Test/Geometry/GraphTests.cs index b42e4dac..2b7b0c7b 100644 --- a/test/BlazorDatasheet.Test/Geometry/GraphTests.cs +++ b/test/BlazorDatasheet.Test/Geometry/GraphTests.cs @@ -43,7 +43,7 @@ public void Add_Remove_Vertices_Test() Assert.AreEqual(1, dg.Prec(v2).Count()); dg.RemoveVertex(v1); - Assert.AreEqual(2, dg.V); + Assert.AreEqual(1, dg.V); Assert.AreEqual(1, dg.E); @@ -53,7 +53,7 @@ public void Add_Remove_Vertices_Test() dg.AddEdge(v1, v2); dg.AddEdge(v2, v3); - Assert.AreEqual(3, dg.V); + Assert.AreEqual(2, dg.V); Assert.AreEqual(2, dg.E); Assert.AreEqual(1, dg.Prec(v2).Count()); } From d464f6a429de892e68fc0d53b8371623b55fa6f1 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 19 May 2024 11:53:03 +1000 Subject: [PATCH 14/26] Fix order of null comparison sort --- .../Commands/SortRangeCommand.cs | 25 +++++++++---------- src/BlazorDatasheet.Formula.Core/CellValue.cs | 6 ++++- .../Commands/SortRangeCommandTests.cs | 18 +++++++++++++ 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs index b4c493bc..c055877b 100644 --- a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs +++ b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs @@ -95,19 +95,18 @@ private int Comparison(RowData x, RowData y) var sortOption = _sortOptions[i]; var xValue = x.GetColumnData(sortOption.ColumnIndex + _region.Left); var yValue = y.GetColumnData(sortOption.ColumnIndex + _region.Left); - - int comparison; - - if (xValue == null && yValue == null) - comparison = 0; - else if (xValue != null && yValue != null) - comparison = xValue.CompareTo(yValue); - else - comparison = xValue == null ? 1 : -1; - - if (comparison == 0) - return comparison; - + + if (xValue?.Data == null && yValue?.Data == null) + return 0; + + // null comparisons shouldn't depend on the sort order - + // null values always end up last. + if (xValue?.Data == null) + return 1; + if (yValue?.Data == null) + return -1; + + int comparison = xValue.CompareTo(yValue); comparison = sortOption.Ascending ? comparison : -comparison; return comparison; diff --git a/src/BlazorDatasheet.Formula.Core/CellValue.cs b/src/BlazorDatasheet.Formula.Core/CellValue.cs index 07276ce2..58f67027 100644 --- a/src/BlazorDatasheet.Formula.Core/CellValue.cs +++ b/src/BlazorDatasheet.Formula.Core/CellValue.cs @@ -250,8 +250,12 @@ public int CompareTo(CellValue? other) { if (other == null) return 1; - if (this.Data == null || other.Data == null) + if(this.Data == null && other.Data == null) + return 0; + if (this.Data == null) return -1; + if (other.Data == null) + return 1; return ((IComparable)this.Data).CompareTo((IComparable)other.Data); } diff --git a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs index d39da8a8..127e0439 100644 --- a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs +++ b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs @@ -113,4 +113,22 @@ public void Sort_Empty_Sheet_Does_Not_Throw_Exception() Action act = () => sortRangeCommand.Execute(sheet); act.Should().NotThrow(); } + + [Test] + public void Sort_Descending_With_Empty_Cells_Puts_Empty_At_End() + { + var sheet = new Sheet(10, 10); + sheet.Cells[0, 0].Value = 1; + sheet.Cells[2, 0].Value = 2; + sheet.Cells[4, 0].Value = 4; + + var so = new ColumnSortOptions(0, false); + var cmd = new SortRangeCommand(new ColumnRegion(0), so); + cmd.Execute(sheet); + sheet.Cells[0, 0].Value.Should().Be(4); + sheet.Cells[1, 0].Value.Should().Be(2); + sheet.Cells[2, 0].Value.Should().Be(1); + sheet.Cells[3, 0].Value.Should().BeNull(); + sheet.Cells[4, 0].Value.Should().BeNull(); + } } \ No newline at end of file From ba61d496de1ede0ff600db8b4ae5e734d351d65b Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 19 May 2024 12:00:24 +1000 Subject: [PATCH 15/26] Update constructor hints --- src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs index c055877b..06f04468 100644 --- a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs +++ b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs @@ -16,7 +16,8 @@ public class SortRangeCommand : IUndoableCommand /// Sorts the specified region on values using the specified sort options. /// /// The region to sort - /// The column sort options, if null the default sort (sort on column 0 ascending) will be used. + /// The column sort options, if null the default sort (sort on column 0 ascending) will be used. + /// If two column values are equal, then the next option will be used for the sort, equivalent to a ThenBy public SortRangeCommand(IRegion region, List? sortOptions = null) { _region = region; @@ -28,7 +29,8 @@ public SortRangeCommand(IRegion region, List? sortOptions = n /// Sorts the specified region on values using the specified sort options. /// /// The region to sort - /// The column sort options, if null the default sort (sort on column 0 ascending) will be used. + /// The column sort options, if null the default sort (sort on column 0 ascending) will be used. + /// If two column values are equal, then the next option will be used for the sort, equivalent to a ThenBy public SortRangeCommand(IRegion region, ColumnSortOptions sortOption) { _region = region; @@ -95,7 +97,7 @@ private int Comparison(RowData x, RowData y) var sortOption = _sortOptions[i]; var xValue = x.GetColumnData(sortOption.ColumnIndex + _region.Left); var yValue = y.GetColumnData(sortOption.ColumnIndex + _region.Left); - + if (xValue?.Data == null && yValue?.Data == null) return 0; From 4b31322a1368f95030fa68f30460935327935a40 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 19 May 2024 12:49:08 +1000 Subject: [PATCH 16/26] Add extension for date -> number -> date conversions. --- .../Extensions/DateToNumberExtensions.cs | 16 ++++++++++++++++ .../Interpreter/Evaluation/CellValueCoercer.cs | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 src/BlazorDatasheet.Formula.Core/Extensions/DateToNumberExtensions.cs diff --git a/src/BlazorDatasheet.Formula.Core/Extensions/DateToNumberExtensions.cs b/src/BlazorDatasheet.Formula.Core/Extensions/DateToNumberExtensions.cs new file mode 100644 index 00000000..c506e15c --- /dev/null +++ b/src/BlazorDatasheet.Formula.Core/Extensions/DateToNumberExtensions.cs @@ -0,0 +1,16 @@ +namespace BlazorDatasheet.Formula.Core.Extensions; + +public static class DateToNumberExtensions +{ + public static double ToNumber(this DateTime date) + { + var epoch = new DateTime(1900, 1, 1); + return (date - epoch).Days; + } + + public static DateTime ToDate(this double number) + { + var epoch = new DateTime(1900, 1, 1); + return epoch.AddDays(number); + } +} \ No newline at end of file diff --git a/src/BlazorDatasheet.Formula.Core/Interpreter/Evaluation/CellValueCoercer.cs b/src/BlazorDatasheet.Formula.Core/Interpreter/Evaluation/CellValueCoercer.cs index 6e8f40d4..2a21e99b 100644 --- a/src/BlazorDatasheet.Formula.Core/Interpreter/Evaluation/CellValueCoercer.cs +++ b/src/BlazorDatasheet.Formula.Core/Interpreter/Evaluation/CellValueCoercer.cs @@ -1,4 +1,5 @@ using BlazorDatasheet.DataStructures.References; +using BlazorDatasheet.Formula.Core.Extensions; using BlazorDatasheet.Formula.Core.Interpreter.References; namespace BlazorDatasheet.Formula.Core.Interpreter.Evaluation; @@ -48,8 +49,7 @@ public bool TryCoerceNumber(CellValue cellValue, out double val) if (cellValue.ValueType == CellValueType.Date) { - var epoch = new DateTime(1900, 1, 1); - val = (((DateTime)cellValue.Data!) - epoch).Days; + val = ((DateTime)cellValue.Data!).ToNumber(); return true; } From a9df48ce2dafba5e51626c007e0fc1ab4f49a636 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 19 May 2024 12:51:09 +1000 Subject: [PATCH 17/26] Add comparison between date and number --- src/BlazorDatasheet.Formula.Core/CellValue.cs | 15 +++++++++++-- .../CellValueType.cs | 22 ++++++++++--------- .../CellValues/CellValueTests.cs | 21 ++++++++++++++++++ 3 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 test/BlazorDatasheet.Test/CellValues/CellValueTests.cs diff --git a/src/BlazorDatasheet.Formula.Core/CellValue.cs b/src/BlazorDatasheet.Formula.Core/CellValue.cs index 58f67027..5ef2c193 100644 --- a/src/BlazorDatasheet.Formula.Core/CellValue.cs +++ b/src/BlazorDatasheet.Formula.Core/CellValue.cs @@ -1,5 +1,6 @@ using BlazorDatasheet.DataStructures.References; using BlazorDatasheet.DataStructures.Util; +using BlazorDatasheet.Formula.Core.Extensions; using BlazorDatasheet.Formula.Core.Interpreter.References; namespace BlazorDatasheet.Formula.Core; @@ -250,14 +251,24 @@ public int CompareTo(CellValue? other) { if (other == null) return 1; - if(this.Data == null && other.Data == null) + if (this.Data == null && other.Data == null) return 0; if (this.Data == null) return -1; if (other.Data == null) return 1; - return ((IComparable)this.Data).CompareTo((IComparable)other.Data); + // special consideration for comparing dates to numbers + if (this.ValueType == CellValueType.Number && other.ValueType == CellValueType.Date) + return ((IComparable)this.Data).CompareTo(((DateTime)other.Data).ToNumber()); + + if (this.ValueType == CellValueType.Date && other.ValueType == CellValueType.Number) + return ((DateTime)this.Data).ToNumber().CompareTo((IComparable)other.Data); + + if (this.ValueType == other.ValueType) + return ((IComparable)this.Data).CompareTo((IComparable)other.Data); + + return this.ValueType.CompareTo(other.ValueType); } public override string ToString() diff --git a/src/BlazorDatasheet.Formula.Core/CellValueType.cs b/src/BlazorDatasheet.Formula.Core/CellValueType.cs index e7470970..fdf23103 100644 --- a/src/BlazorDatasheet.Formula.Core/CellValueType.cs +++ b/src/BlazorDatasheet.Formula.Core/CellValueType.cs @@ -2,14 +2,16 @@ public enum CellValueType { - Empty, - Text, - Number, - Logical, - Date, - Error, - Array, - Unknown, - Sequence, - Reference + // order here is important for cell value comparisons + // excel and google sheets seem to use COMPLEX > BOOL > TEXT > NUM + Empty = 0, + Error = 1, + Array = 2, + Unknown = 3, + Sequence = 4, + Reference = 5, + Number = 6, + Date = 7, + Text = 8, + Logical = 9, } \ No newline at end of file diff --git a/test/BlazorDatasheet.Test/CellValues/CellValueTests.cs b/test/BlazorDatasheet.Test/CellValues/CellValueTests.cs new file mode 100644 index 00000000..89b78156 --- /dev/null +++ b/test/BlazorDatasheet.Test/CellValues/CellValueTests.cs @@ -0,0 +1,21 @@ +using System; +using NUnit.Framework; +using BlazorDatasheet.Formula.Core; +using BlazorDatasheet.Formula.Core.Extensions; +using FluentAssertions; + +namespace BlazorDatasheet.Test.CellValues; + +public class CellValueTests +{ + [Test] + public void Cell_Value_Date_Compares_With_Number() + { + var c1 = CellValue.Date(1.0.ToDate()); + var c2 = CellValue.Number(0); + var c3 = CellValue.Date((-1.0).ToDate()); + + c2.CompareTo(c1).Should().Be(-1); + c2.CompareTo(c3).Should().Be(1); + } +} \ No newline at end of file From f613c1ed88fcdf1e36b070631b76f0d4a6eced6b Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 19 May 2024 13:28:03 +1000 Subject: [PATCH 18/26] Change validation store to sparse matrix by Rows instead of Cols --- src/BlazorDatasheet.Core/Data/Cells/CellStore.Validation.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Validation.cs b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Validation.cs index 6bbf6af8..1b659136 100644 --- a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Validation.cs +++ b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Validation.cs @@ -10,7 +10,7 @@ public partial class CellStore /// /// Stores whether cells are valid. /// - private readonly IMatrixDataStore _validStore = new SparseMatrixStoreByCols(); + private readonly IMatrixDataStore _validStore = new SparseMatrixStoreByRows(); internal void ValidateRegion(IRegion region) { From d731106ca865c859373bac0d273a29fdb0e31263 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 19 May 2024 13:46:34 +1000 Subject: [PATCH 19/26] Use sub-store for formula sorting. --- .../Commands/SortRangeCommand.cs | 11 ++++--- .../Data/Cells/CellStore.Formula.cs | 2 +- .../Store/IMatrixDataStore.cs | 9 ++++++ .../Store/SparseList.cs | 19 ++++++++++-- .../Store/SparseMatrixStoreByCols.cs | 5 ++++ .../Store/SparseMatrixStoreByRows.cs | 6 ++-- .../Commands/SortRangeCommandTests.cs | 19 ++++++++++++ .../Store/DataStoreByRowsTests.cs | 29 ++++++++++++++++++- 8 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs index 06f04468..dee46211 100644 --- a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs +++ b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs @@ -40,9 +40,11 @@ public SortRangeCommand(IRegion region, ColumnSortOptions sortOption) public bool Execute(Sheet sheet) { var store = sheet.Cells.GetCellDataStore(); + var rowCollection = store.GetNonEmptyRowData(_region); var rowIndices = new Span(rowCollection.RowIndicies); var rowData = new Span>(rowCollection.Rows); + rowData.Sort(rowIndices, Comparison); _sortedRegion = new Region(_region.Top, _region.Top + rowIndices.Length, _region.Left, _region.Right); @@ -53,7 +55,7 @@ public bool Execute(Sheet sheet) sheet.BatchUpdates(); - var formulaData = sheet.Cells.GetFormulaStore().GetNonEmptyRowData(_region); + var formulaData = sheet.Cells.GetFormulaStore().GetSubMatrix(_region, false); // clear any row data that has been shifted (which should be all non-empty rows) for (int i = 0; i < rowData.Length; i++) @@ -72,7 +74,7 @@ public bool Execute(Sheet sheet) { var col = rowData[i].ColumnIndices[j]; var val = rowData[i].Values[j]; - var formula = formulaData.GetValue(oldRowNo, col); + var formula = formulaData.Get(oldRowNo, col); if (formula == null) sheet.Cells.SetValueImpl(newRowNo, col, val); else @@ -123,7 +125,8 @@ public bool Undo(Sheet sheet) return true; var rowCollection = sheet.Cells.GetCellDataStore().GetRowData(_sortedRegion); - var formulaCollection = sheet.Cells.GetFormulaStore().GetRowData(_sortedRegion); + var formulaCollection = sheet.Cells.GetFormulaStore().GetSubMatrix(_sortedRegion, false); + sheet.BatchUpdates(); sheet.Cells.ClearCellsImpl(new[] { _sortedRegion }); @@ -136,7 +139,7 @@ public bool Undo(Sheet sheet) for (int j = 0; j < rowData[i].ColumnIndices.Length; j++) { var col = rowData[i].ColumnIndices[j]; - var formula = formulaCollection.GetValue(rowIndices[i], col); + var formula = formulaCollection.Get(rowIndices[i], col); if (formula == null) { var val = rowData[i].Values[j]; diff --git a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs index 5a642df4..d3b69556 100644 --- a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs +++ b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Formula.cs @@ -135,5 +135,5 @@ public void SetFormula(int row, int col, CellFormula parsedFormula) _sheet.Commands.ExecuteCommand(new SetParsedFormulaCommand(row, col, parsedFormula)); } - public IMatrixDataStore GetFormulaStore() => _formulaStore; + internal IMatrixDataStore GetFormulaStore() => _formulaStore; } \ No newline at end of file diff --git a/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs b/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs index 8e622415..a337867d 100644 --- a/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs +++ b/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs @@ -145,4 +145,13 @@ IEnumerable GetNonEmptyPositions(IRegion region) => /// /// public T[][] GetData(IRegion region); + + /// + /// Returns a sub-matrix containing only the data in the region specified. + /// If the is true, the new store will have the top-left corner at 0,0. + /// + /// The region to extract data from + /// If true, the new store will have the top-left corner at 0,0 + /// + IMatrixDataStore GetSubMatrix(IRegion region, bool newStoreResetsOffsets = true); } \ No newline at end of file diff --git a/src/BlazorDatasheet.DataStructures/Store/SparseList.cs b/src/BlazorDatasheet.DataStructures/Store/SparseList.cs index 1fc81744..9ae993c9 100644 --- a/src/BlazorDatasheet.DataStructures/Store/SparseList.cs +++ b/src/BlazorDatasheet.DataStructures/Store/SparseList.cs @@ -202,7 +202,7 @@ public int GetNextNonEmptyItemKey(int key) return Values.Keys[index]; } - + /// /// Returns the next non-empty value pair after & including the index given. If none found, returns null /// @@ -269,5 +269,20 @@ public T[] GetDataBetween(int i0, int i1) return res; } - + public SparseList GetSubList(int i0, int i1, bool resetIndicesInNewList) + { + var vals = new SortedList(); + var nonEmpty = GetNonEmptyDataBetween(i0, i1); + + foreach (var dp in nonEmpty) + { + var newIndex = resetIndicesInNewList ? dp.itemIndex - i0 : dp.itemIndex; + vals.Add(newIndex, dp.data); + } + + return new SparseList(_defaultValueIfEmpty) + { + Values = vals + }; + } } \ No newline at end of file diff --git a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs index 6cce8d11..9f8e4902 100644 --- a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs +++ b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs @@ -249,6 +249,11 @@ public T[][] GetData(IRegion region) return result; } + public IMatrixDataStore GetSubMatrix(IRegion region, bool newStoreResetsOffsets = true) + { + throw new NotImplementedException(); + } + /// /// Get non empty data that exist in the bounds given /// diff --git a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs index c390abe9..e94929b5 100644 --- a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs +++ b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs @@ -238,14 +238,12 @@ public IMatrixDataStore GetSubMatrix(IRegion region, bool newStoreResetsOffse int c1 = region.Right; int rowOffset = newStoreResetsOffsets ? r0 : 0; - int colOffset = newStoreResetsOffsets ? c0 : 0; var nonEmptyRows = _rows.GetNonEmptyDataBetween(r0, r1); foreach (var row in nonEmptyRows) { - var data = row.data.GetNonEmptyDataBetween(c0, c1); - foreach (var col in data) - store.Set(row.itemIndex - rowOffset, col.itemIndex - colOffset, col.data); + var subList = row.data.GetSubList(c0, c1, newStoreResetsOffsets); + store._rows.Set(row.itemIndex - rowOffset, subList); } return store; diff --git a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs index 127e0439..2521de91 100644 --- a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs +++ b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs @@ -131,4 +131,23 @@ public void Sort_Descending_With_Empty_Cells_Puts_Empty_At_End() sheet.Cells[3, 0].Value.Should().BeNull(); sheet.Cells[4, 0].Value.Should().BeNull(); } + + [Test] + public void Sort_Command_Moves_Cell_Types() + { + var sheet = new Sheet(10, 10); + sheet.Cells[0, 0].Value = 2; + sheet.Cells[1, 0].Value = 1; + sheet.Cells[1, 0].Type = "bool"; + + var cmd = new SortRangeCommand(new ColumnRegion(0)); + cmd.Execute(sheet); + + sheet.Cells[0, 0].Type.Should().Be("bool"); + cmd.Undo(sheet); + + sheet.Cells[1, 0].Type.Should().Be("bool"); + sheet.Cells[0, 0].Type.Should().Be("text"); + } + } \ No newline at end of file diff --git a/test/BlazorDatasheet.Test/Store/DataStoreByRowsTests.cs b/test/BlazorDatasheet.Test/Store/DataStoreByRowsTests.cs index 0c5a097a..5e8cc157 100644 --- a/test/BlazorDatasheet.Test/Store/DataStoreByRowsTests.cs +++ b/test/BlazorDatasheet.Test/Store/DataStoreByRowsTests.cs @@ -211,7 +211,7 @@ public void Remove_Region_Removes_Region() } [Test] - public void Sub_Matrix_Tests() + public void Sub_Matrix_Tests_With_Reset_Offsets() { var store = (SparseMatrixStoreByRows)GetStore(); for (int row = 0; row < 10; row++) @@ -236,4 +236,31 @@ public void Sub_Matrix_Tests() } } } + + [Test] + public void Sub_Matrix_Tests_With_No_Reset_Offsets() + { + var store = (SparseMatrixStoreByRows)GetStore(); + for (int row = 0; row < 10; row++) + { + for (int col = 0; col < 10; col++) + { + store.Set(row, col, $"{row},{col}"); + } + } + + var rowLen = 4; + var r0 = 2; + var colLen = 5; + var c0 = 3; + var subMatrix = store.GetSubMatrix(new Region(r0, r0 + rowLen - 1, c0, c0 + colLen - 1), + newStoreResetsOffsets: false); + for (int row = 0; row < rowLen; row++) + { + for (int col = 0; col < colLen; col++) + { + subMatrix.Get(row + r0, col + c0).Should().Be($"{row + r0},{col + c0}"); + } + } + } } \ No newline at end of file From 299c280fcee731a43d1d920f9467a29bc16087f3 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 19 May 2024 14:57:31 +1000 Subject: [PATCH 20/26] Fix multi-column sorting --- .../Commands/SortRangeCommand.cs | 18 +++++++++------- .../Commands/SortRangeCommandTests.cs | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs index dee46211..8a990bea 100644 --- a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs +++ b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs @@ -10,7 +10,7 @@ public class SortRangeCommand : IUndoableCommand private readonly IRegion _region; private IRegion? _sortedRegion; private readonly List _sortOptions; - private int[] oldIndices = Array.Empty(); + private int[] _oldIndices = Array.Empty(); /// /// Sorts the specified region on values using the specified sort options. @@ -55,7 +55,7 @@ public bool Execute(Sheet sheet) sheet.BatchUpdates(); - var formulaData = sheet.Cells.GetFormulaStore().GetSubMatrix(_region, false); + var formulaData = sheet.Cells.GetFormulaStore().GetSubStore(_region, false); // clear any row data that has been shifted (which should be all non-empty rows) for (int i = 0; i < rowData.Length; i++) @@ -87,7 +87,7 @@ public bool Execute(Sheet sheet) sheet.EndBatchUpdates(); - oldIndices = rowIndices.ToArray(); + _oldIndices = rowIndices.ToArray(); return true; } @@ -101,7 +101,7 @@ private int Comparison(RowData x, RowData y) var yValue = y.GetColumnData(sortOption.ColumnIndex + _region.Left); if (xValue?.Data == null && yValue?.Data == null) - return 0; + continue; // null comparisons shouldn't depend on the sort order - // null values always end up last. @@ -111,6 +111,10 @@ private int Comparison(RowData x, RowData y) return -1; int comparison = xValue.CompareTo(yValue); + + if(comparison == 0) + continue; + comparison = sortOption.Ascending ? comparison : -comparison; return comparison; @@ -125,7 +129,7 @@ public bool Undo(Sheet sheet) return true; var rowCollection = sheet.Cells.GetCellDataStore().GetRowData(_sortedRegion); - var formulaCollection = sheet.Cells.GetFormulaStore().GetSubMatrix(_sortedRegion, false); + var formulaCollection = sheet.Cells.GetFormulaStore().GetSubStore(_sortedRegion, false); sheet.BatchUpdates(); @@ -133,9 +137,9 @@ public bool Undo(Sheet sheet) var rowData = rowCollection.Rows; var rowIndices = rowCollection.RowIndicies; - for (int i = 0; i < oldIndices.Length; i++) + for (int i = 0; i < _oldIndices.Length; i++) { - var newRowNo = oldIndices[i]; + var newRowNo = _oldIndices[i]; for (int j = 0; j < rowData[i].ColumnIndices.Length; j++) { var col = rowData[i].ColumnIndices[j]; diff --git a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs index 2521de91..0b359326 100644 --- a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs +++ b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs @@ -149,5 +149,26 @@ public void Sort_Command_Moves_Cell_Types() sheet.Cells[1, 0].Type.Should().Be("bool"); sheet.Cells[0, 0].Type.Should().Be("text"); } + + [Test] + public void Sort_On_Multiple_Columns_Sorts_Correctly() + { + var sheet = new Sheet(10, 10); + sheet.Range("A1:A4")!.Value = 5; + sheet.Range("B1")!.Value = 2; + sheet.Range("B4")!.Value = 1; + + var options = new List() + { + new ColumnSortOptions(0, true), + new ColumnSortOptions(1, true) + }; + + var cmd = new SortRangeCommand(new ColumnRegion(0, 1), options); + cmd.Execute(sheet); + + sheet.Cells[0, 1].Value.Should().Be(1); + sheet.Cells[1, 1].Value.Should().Be(2); + } } \ No newline at end of file From 163e448690672acafd391f4aef288152539cb915 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 19 May 2024 14:58:01 +1000 Subject: [PATCH 21/26] Add sub-store to rect storage --- .../Store/ConsolidatedDataStore.cs | 18 ++- .../Store/IMatrixDataStore.cs | 31 +---- .../Store/IStore.cs | 30 +++++ .../Store/MergeRegionDataStore.cs | 9 +- .../Store/RegionDataStore.cs | 106 +++++++++++++----- .../Store/SparseMatrixStoreByCols.cs | 2 +- .../Store/SparseMatrixStoreByRows.cs | 2 +- .../Store/DataStoreByRowsTests.cs | 4 +- .../Store/RegionStoreTests.cs | 24 ++++ 9 files changed, 159 insertions(+), 67 deletions(-) create mode 100644 src/BlazorDatasheet.DataStructures/Store/IStore.cs diff --git a/src/BlazorDatasheet.DataStructures/Store/ConsolidatedDataStore.cs b/src/BlazorDatasheet.DataStructures/Store/ConsolidatedDataStore.cs index 7f859a1b..f80fc31a 100644 --- a/src/BlazorDatasheet.DataStructures/Store/ConsolidatedDataStore.cs +++ b/src/BlazorDatasheet.DataStructures/Store/ConsolidatedDataStore.cs @@ -13,19 +13,24 @@ public class ConsolidatedDataStore : RegionDataStore where T : IEquatable< /// /// Keeps track of the regions that each data apply to. /// - private Dictionary> _dataMaps { get; } + private readonly Dictionary> _dataMaps; public ConsolidatedDataStore() : base() { _dataMaps = new Dictionary>(); } + + public ConsolidatedDataStore(int minArea, bool expandWhenInsertAfter) : base(minArea, expandWhenInsertAfter) + { + _dataMaps = new Dictionary>(); + } protected override RegionRestoreData Add(DataRegion dataRegion) { var (regionsToRemove, regionsToAdd) = Consolidate(dataRegion); foreach (var removal in regionsToRemove) - _tree.Delete(removal); - _tree.BulkLoad(regionsToAdd); + Tree.Delete(removal); + Tree.BulkLoad(regionsToAdd); _dataMaps.TryAdd(dataRegion.Data, regionsToAdd.Select(x => x.Region).ToList()); @@ -57,6 +62,11 @@ public override RegionRestoreData Clear(IRegion region, T data) return restoreData; } + protected override RegionDataStore GetEmptyClone() + { + return new ConsolidatedDataStore(); + } + /// /// Returns the regions associated with the given data. /// @@ -66,7 +76,7 @@ public IEnumerable GetRegions(T data) { //TODO use data maps... or delete them // since we won't be calling this very often it doesn't have to be efficient? - return _tree.Search().Where(x => x.Data.Equals(data)).Select(x => x.Region); + return Tree.Search().Where(x => x.Data.Equals(data)).Select(x => x.Region); } /// diff --git a/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs b/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs index a337867d..d63691b7 100644 --- a/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs +++ b/src/BlazorDatasheet.DataStructures/Store/IMatrixDataStore.cs @@ -3,16 +3,8 @@ namespace BlazorDatasheet.DataStructures.Store; -public interface IMatrixDataStore +public interface IMatrixDataStore : IStore> { - /// - /// Returns whether the the store contains any non-empty data at the row, column specified. - /// - /// - /// - /// - public bool Contains(int row, int col); - /// /// Returns the data at the row, column specified. If it is empty, returns the default of T. /// @@ -21,14 +13,6 @@ public interface IMatrixDataStore /// public T? Get(int row, int col); - /// - /// Sets the data at the row, column specified. - /// - /// - /// - /// - public MatrixRestoreData Set(int row, int col, T value); - /// /// Removes the value at the row/column from the store but does not affect the rows/columns around it. /// @@ -38,13 +22,6 @@ public interface IMatrixDataStore public MatrixRestoreData Clear(IEnumerable positions); - /// - /// Clears data inside the given region but does not affect the rows/columns arround it. - /// - /// - /// - public MatrixRestoreData Clear(IRegion region); - /// /// Clears data inside the specified regions but does not affect the rows/columsn around it. /// @@ -145,13 +122,13 @@ IEnumerable GetNonEmptyPositions(IRegion region) => /// /// public T[][] GetData(IRegion region); - + /// - /// Returns a sub-matrix containing only the data in the region specified. + /// Returns a sub-store containing only the data in the region specified. /// If the is true, the new store will have the top-left corner at 0,0. /// /// The region to extract data from /// If true, the new store will have the top-left corner at 0,0 /// - IMatrixDataStore GetSubMatrix(IRegion region, bool newStoreResetsOffsets = true); + IMatrixDataStore GetSubStore(IRegion region, bool newStoreResetsOffsets = true); } \ No newline at end of file diff --git a/src/BlazorDatasheet.DataStructures/Store/IStore.cs b/src/BlazorDatasheet.DataStructures/Store/IStore.cs new file mode 100644 index 00000000..5102cd96 --- /dev/null +++ b/src/BlazorDatasheet.DataStructures/Store/IStore.cs @@ -0,0 +1,30 @@ +using BlazorDatasheet.DataStructures.Geometry; + +namespace BlazorDatasheet.DataStructures.Store; + +public interface IStore +{ + /// + /// Returns whether the the store contains any non-empty data at the row, column specified. + /// + /// + /// + /// + public bool Contains(int row, int col); + + /// + /// Sets the data at the row, column specified. + /// + /// + /// + /// + public TRestoreData Set(int row, int col, T value); + + /// + /// Clears data inside the given region but does not affect the rows/columns arround it. + /// + /// + /// + public TRestoreData Clear(IRegion region); + +} \ No newline at end of file diff --git a/src/BlazorDatasheet.DataStructures/Store/MergeRegionDataStore.cs b/src/BlazorDatasheet.DataStructures/Store/MergeRegionDataStore.cs index a2f04437..6b91a558 100644 --- a/src/BlazorDatasheet.DataStructures/Store/MergeRegionDataStore.cs +++ b/src/BlazorDatasheet.DataStructures/Store/MergeRegionDataStore.cs @@ -13,6 +13,11 @@ public MergeRegionDataStore(int minArea = 0, bool expandOnInsert = true) : base( { } + protected override RegionDataStore GetEmptyClone() + { + return new MergeRegionDataStore(MinArea, ExpandWhenInsertAfter); + } + protected override RegionRestoreData Add(DataRegion dataRegion) { // we have the valid assumption that only one region will exist at each position @@ -68,9 +73,9 @@ protected override RegionRestoreData Add(DataRegion dataRegion) toAdd.AddRange(breakNewData.Select(x => new DataRegion(dataRegion.Data, x))); foreach (var r in toRemove) - _tree.Delete(r); + Tree.Delete(r); - _tree.BulkLoad(toAdd); + Tree.BulkLoad(toAdd); return new RegionRestoreData() { diff --git a/src/BlazorDatasheet.DataStructures/Store/RegionDataStore.cs b/src/BlazorDatasheet.DataStructures/Store/RegionDataStore.cs index 71d27620..01145495 100644 --- a/src/BlazorDatasheet.DataStructures/Store/RegionDataStore.cs +++ b/src/BlazorDatasheet.DataStructures/Store/RegionDataStore.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Data.SqlTypes; +using System.Net; using BlazorDatasheet.DataStructures.Geometry; using BlazorDatasheet.DataStructures.RTree; using BlazorDatasheet.DataStructures.Util; @@ -10,11 +11,11 @@ namespace BlazorDatasheet.DataStructures.Store; /// A wrapper around an RTree enabling storing data in regions. /// /// Data type -public class RegionDataStore where T : IEquatable +public class RegionDataStore : IStore> where T : IEquatable { - private readonly int _minArea; - private readonly bool _expandWhenInsertAfter; - protected RTree> _tree; + protected readonly int MinArea; + protected readonly bool ExpandWhenInsertAfter; + protected readonly RTree> Tree; /// /// @@ -24,9 +25,14 @@ public class RegionDataStore where T : IEquatable /// When set to true, when a row or column is inserted just below/right of a region, the region is expanded public RegionDataStore(int minArea = 0, bool expandWhenInsertAfter = true) { - _minArea = minArea; - _expandWhenInsertAfter = expandWhenInsertAfter; - _tree = new RTree>(); + MinArea = minArea; + ExpandWhenInsertAfter = expandWhenInsertAfter; + Tree = new RTree>(); + } + + public bool Contains(int row, int col) + { + return GetDataRegions(row, col).Any(); } /// @@ -35,7 +41,7 @@ public RegionDataStore(int minArea = 0, bool expandWhenInsertAfter = true) /// public IEnumerable> GetAllDataRegions() { - return _tree.Search(); + return Tree.Search(); } public IEnumerable> GetDataRegions(int row, int col) @@ -59,7 +65,7 @@ public IEnumerable> GetDataRegions(IEnumerable regions) public IEnumerable> GetDataRegions(IRegion region) { var env = region.ToEnvelope(); - return _tree.Search(env); + return Tree.Search(env); } public IEnumerable GetData(int row, int col) @@ -79,7 +85,7 @@ public IEnumerable GetData(IRegion region) /// internal IEnumerable> GetContainedRegions(IRegion region) { - return _tree.Search(region.ToEnvelope()) + return Tree.Search(region.ToEnvelope()) .Where(x => region.Contains(x.Region)); } @@ -114,7 +120,7 @@ public void InsertCols(int colIndex, int nCols, bool? expandNeighbouring = null) private void InsertRowsOrColumnAndShift(int index, int nRowsOrCol, Axis axis, bool? expandNeighbouring) { - var expand = expandNeighbouring ?? _expandWhenInsertAfter; + var expand = expandNeighbouring ?? ExpandWhenInsertAfter; // As an example for inserting rows, there are three things that can happen // 1. Any regions that intersect the index should be expanded by nRows @@ -135,7 +141,7 @@ private void InsertRowsOrColumnAndShift(int index, int nRowsOrCol, Axis axis, bo var clonedRegion = r.Region.Clone(); clonedRegion.Expand(axis == Axis.Row ? Edge.Bottom : Edge.Right, nRowsOrCol); dataRegionsToAdd.Add(new DataRegion(r.Data, clonedRegion)); - _tree.Delete(r); + Tree.Delete(r); } // index - 1 because the top of the region has to be above the region to shift it down @@ -147,12 +153,12 @@ private void InsertRowsOrColumnAndShift(int index, int nRowsOrCol, Axis axis, bo var dCol = axis == Axis.Col ? nRowsOrCol : 0; clonedRegion.Shift(dRow, dCol); dataRegionsToAdd.Add(new DataRegion(r.Data, clonedRegion)); - _tree.Delete(r); + Tree.Delete(r); } foreach (var dr in dataRegionsToAdd) { - _tree.Insert(dr); + Tree.Insert(dr); } } @@ -197,9 +203,9 @@ private RegionRestoreData RemoveRowsOrColumsAndShift(int start, int end, Axis // if we remove top two rows there is a width of 1 and if the min area is less than one, it should be removed. var cuts = r.Region.Break(region); var cutArea = cuts.Sum(x => x.Area); - if (cutArea <= _minArea) + if (cutArea <= MinArea) { - _tree.Delete(r); + Tree.Delete(r); removed.Add(r); continue; } @@ -216,7 +222,7 @@ private RegionRestoreData RemoveRowsOrColumsAndShift(int start, int end, Axis var clonedRegion = r.Region.Clone(); clonedRegion.Contract(axis == Axis.Row ? Edge.Bottom : Edge.Right, nOverlapping); newDataRegions.Add(new DataRegion(r.Data, clonedRegion)); - _tree.Delete(r); + Tree.Delete(r); } else // Add the bits that aren't intersecting back in only { @@ -231,7 +237,7 @@ private RegionRestoreData RemoveRowsOrColumsAndShift(int start, int end, Axis } removed.Add(r); - _tree.Delete(r); + Tree.Delete(r); } } @@ -239,7 +245,7 @@ private RegionRestoreData RemoveRowsOrColumsAndShift(int start, int end, Axis var next = GetAfter(end, axis); foreach (var dataRegion in next) { - _tree.Delete(dataRegion); + Tree.Delete(dataRegion); var copiedRegion = dataRegion.Region.Clone(); var nRows = axis == Axis.Row ? (end - start) + 1 : 0; var nCols = axis == Axis.Col ? (end - start) + 1 : 0; @@ -247,7 +253,7 @@ private RegionRestoreData RemoveRowsOrColumsAndShift(int start, int end, Axis newDataRegions.Add(new DataRegion(dataRegion.Data, copiedRegion)); } - _tree.BulkLoad(newDataRegions); + Tree.BulkLoad(newDataRegions); return new RegionRestoreData() { @@ -320,6 +326,38 @@ public virtual RegionRestoreData Clear(IRegion region) return Clear(region, GetDataRegions(region)); } + /// + /// Returns a sub-store containing only the data in the region specified. + /// If the is true, the new store will have the top-left corner at 0,0. + /// + /// The region to extract data from + /// If true, the new store will have the top-left corner at 0,0 + /// + public RegionDataStore GetSubStore(IRegion region, bool newStoreResetsOffsets = true) + { + var store = GetEmptyClone(); + var data = + GetDataRegions(region) + .Select(x => + { + var newRegion = x.Region.GetIntersection(region)!; + if (newStoreResetsOffsets) + { + newRegion.Shift(-region.Top, -region.Left); + } + + return new DataRegion(x.Data, newRegion); + }); + + store.AddRange(data); + return store; + } + + protected virtual RegionDataStore GetEmptyClone() + { + return new RegionDataStore(MinArea, ExpandWhenInsertAfter); + } + private RegionRestoreData Clear(IRegion region, IEnumerable> dataRegions) { var dataRegionsToRemove = new List>(); @@ -336,9 +374,9 @@ private RegionRestoreData Clear(IRegion region, IEnumerable> da } foreach (var regionToRemove in dataRegionsToRemove) - _tree.Delete(regionToRemove); + Tree.Delete(regionToRemove); - _tree.BulkLoad(dataRegionsToAdd); + Tree.BulkLoad(dataRegionsToAdd); return new RegionRestoreData() { @@ -366,7 +404,7 @@ public RegionRestoreData Copy(IRegion fromRegion, CellPosition toPosition) d.UpdateEnvelope(); } - _tree.BulkLoad(dataToCopy); + Tree.BulkLoad(dataToCopy); restoreData.RegionsAdded.AddRange(dataToCopy); return restoreData; @@ -396,19 +434,27 @@ private List> Grab(IRegion region) /// protected virtual RegionRestoreData Add(DataRegion dataRegion) { - _tree.Insert(dataRegion); + Tree.Insert(dataRegion); return new RegionRestoreData() { RegionsAdded = new List>() { dataRegion } }; } + public virtual RegionRestoreData Set(int row, int col, T value) + { + var region = new Region(row, col); + var restoreData = Clear(region); + restoreData.Merge(this.Add(new DataRegion(value, region))); + return restoreData; + } + public void Delete(DataRegion dataRegion) { - _tree.Delete(dataRegion); + Tree.Delete(dataRegion); } - public bool Any() => _tree.Count > 0; + public bool Any() => Tree.Count > 0; public bool Any(int row, int col) { @@ -420,18 +466,18 @@ public bool Any(IRegion region) return GetDataRegions(region).Any(); } - protected void AddRange(List> dataRegions) + protected void AddRange(IEnumerable> dataRegions) { - _tree.BulkLoad(dataRegions); + Tree.BulkLoad(dataRegions); } public virtual void Restore(RegionRestoreData restoreData) { foreach (var added in restoreData.RegionsAdded) { - _tree.Delete(added); + Tree.Delete(added); } - _tree.BulkLoad(restoreData.RegionsRemoved); + Tree.BulkLoad(restoreData.RegionsRemoved); } } \ No newline at end of file diff --git a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs index 9f8e4902..3708ff45 100644 --- a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs +++ b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByCols.cs @@ -249,7 +249,7 @@ public T[][] GetData(IRegion region) return result; } - public IMatrixDataStore GetSubMatrix(IRegion region, bool newStoreResetsOffsets = true) + public IMatrixDataStore GetSubStore(IRegion region, bool newStoreResetsOffsets = true) { throw new NotImplementedException(); } diff --git a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs index e94929b5..0e987b71 100644 --- a/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs +++ b/src/BlazorDatasheet.DataStructures/Store/SparseMatrixStoreByRows.cs @@ -229,7 +229,7 @@ public T[][] GetData(IRegion region) return res; } - public IMatrixDataStore GetSubMatrix(IRegion region, bool newStoreResetsOffsets = true) + public IMatrixDataStore GetSubStore(IRegion region, bool newStoreResetsOffsets = true) { var store = new SparseMatrixStoreByRows(_defaultIfEmpty); int r0 = region.Top; diff --git a/test/BlazorDatasheet.Test/Store/DataStoreByRowsTests.cs b/test/BlazorDatasheet.Test/Store/DataStoreByRowsTests.cs index 5e8cc157..663b5aa0 100644 --- a/test/BlazorDatasheet.Test/Store/DataStoreByRowsTests.cs +++ b/test/BlazorDatasheet.Test/Store/DataStoreByRowsTests.cs @@ -226,7 +226,7 @@ public void Sub_Matrix_Tests_With_Reset_Offsets() var r0 = 2; var colLen = 5; var c0 = 3; - var subMatrix = store.GetSubMatrix(new Region(r0, r0 + rowLen - 1, c0, c0 + colLen - 1), + var subMatrix = store.GetSubStore(new Region(r0, r0 + rowLen - 1, c0, c0 + colLen - 1), newStoreResetsOffsets: true); for (int row = 0; row < rowLen; row++) { @@ -253,7 +253,7 @@ public void Sub_Matrix_Tests_With_No_Reset_Offsets() var r0 = 2; var colLen = 5; var c0 = 3; - var subMatrix = store.GetSubMatrix(new Region(r0, r0 + rowLen - 1, c0, c0 + colLen - 1), + var subMatrix = store.GetSubStore(new Region(r0, r0 + rowLen - 1, c0, c0 + colLen - 1), newStoreResetsOffsets: false); for (int row = 0; row < rowLen; row++) { diff --git a/test/BlazorDatasheet.Test/Store/RegionStoreTests.cs b/test/BlazorDatasheet.Test/Store/RegionStoreTests.cs index 20646116..663b3248 100644 --- a/test/BlazorDatasheet.Test/Store/RegionStoreTests.cs +++ b/test/BlazorDatasheet.Test/Store/RegionStoreTests.cs @@ -183,4 +183,28 @@ public void Clear_All_Data_Clears_All_Data() store.Clear(new Region(0, 5, 0, 5)); store.GetDataRegions(new Region(0, 5, 0, 5)).Should().BeEmpty(); } + + [Test] + public void Get_Sub_Store_Gets_Sub_Storage_Works_When_Not_Resetting_Indices() + { + var store = new RegionDataStore(); + store.Add(new ColumnRegion(1), 1); + store.Add(new ColumnRegion(2), 2); + + var subStore = store.GetSubStore(new RowRegion(3, 4), false); + subStore.GetData(3, 1).Should().BeEquivalentTo(new int[] { 1 }); + subStore.GetData(4, 2).Should().BeEquivalentTo(new int[] { 2 }); + } + + [Test] + public void Get_Sub_Store_Gets_Sub_Storage_Works_When_Resetting_Indices() + { + var store = new RegionDataStore(); + store.Add(new ColumnRegion(1), 1); + store.Add(new ColumnRegion(2), 2); + + var subStore = store.GetSubStore(new RowRegion(3, 4), newStoreResetsOffsets: true); + subStore.GetData(0, 1).Should().BeEquivalentTo(new int[] { 1 }); + subStore.GetData(1, 2).Should().BeEquivalentTo(new int[] { 2 }); + } } \ No newline at end of file From e094253081d6564c042819d07566043aa7c123bb Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 19 May 2024 15:05:07 +1000 Subject: [PATCH 22/26] Add sort buttons to home page example --- .../Pages/Index.razor | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/BlazorDatasheet.SharedPages/Pages/Index.razor b/src/BlazorDatasheet.SharedPages/Pages/Index.razor index bea21689..9f7ba40c 100644 --- a/src/BlazorDatasheet.SharedPages/Pages/Index.razor +++ b/src/BlazorDatasheet.SharedPages/Pages/Index.razor @@ -9,6 +9,7 @@ @using BlazorDatasheet.Core.Formats.DefaultConditionalFormats @using BlazorDatasheet.Core.Validation @using System.Drawing +@using BlazorDatasheet.Core.Commands Index @@ -41,6 +42,8 @@ + +
@@ -66,6 +69,24 @@ private Datasheet _datasheet; + private void SortSelectionDesc() + { + if (Sheet.Selection.ActiveRegion == null) + return; + Sheet.Commands.ExecuteCommand( + new SortRangeCommand(Sheet.Selection.ActiveRegion, new ColumnSortOptions(0, false)) + ); + } + + private void SortSelectionAsc() + { + if (Sheet.Selection.ActiveRegion == null) + return; + Sheet.Commands.ExecuteCommand( + new SortRangeCommand(Sheet.Selection.ActiveRegion, new ColumnSortOptions(0, true)) + ); + } + private void InsertRowAfterSelection() { if (Sheet.Selection.ActiveRegion == null) From 71d20641e6fe82339a2e97db2ac6036f2195305d Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 19 May 2024 16:05:15 +1000 Subject: [PATCH 23/26] Move cell types on sort. --- .../Commands/SortRangeCommand.cs | 27 ++++++++++++++++--- .../Data/Cells/CellStore.Types.cs | 9 +++++-- .../Commands/SortRangeCommandTests.cs | 11 ++++---- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs index 8a990bea..8fa6757a 100644 --- a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs +++ b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs @@ -1,4 +1,5 @@ using BlazorDatasheet.Core.Data; +using BlazorDatasheet.Core.Data.Cells; using BlazorDatasheet.DataStructures.Geometry; using BlazorDatasheet.DataStructures.Store; using BlazorDatasheet.Formula.Core; @@ -11,6 +12,7 @@ public class SortRangeCommand : IUndoableCommand private IRegion? _sortedRegion; private readonly List _sortOptions; private int[] _oldIndices = Array.Empty(); + private RegionRestoreData _typeRestoreData = new(); /// /// Sorts the specified region on values using the specified sort options. @@ -56,6 +58,8 @@ public bool Execute(Sheet sheet) sheet.BatchUpdates(); var formulaData = sheet.Cells.GetFormulaStore().GetSubStore(_region, false); + var typeData = + (ConsolidatedDataStore)sheet.Cells.GetTypeStore().GetSubStore(_region, false); // clear any row data that has been shifted (which should be all non-empty rows) for (int i = 0; i < rowData.Length; i++) @@ -63,6 +67,7 @@ public bool Execute(Sheet sheet) var row = rowData[i].Row; var rowReg = new Region(row, row, _region.Left, _region.Right); sheet.Cells.ClearCellsImpl(new[] { rowReg }); + _typeRestoreData.Merge(sheet.Cells.GetTypeStore().Clear(rowReg)); } for (int i = 0; i < rowData.Length; i++) @@ -75,6 +80,10 @@ public bool Execute(Sheet sheet) var col = rowData[i].ColumnIndices[j]; var val = rowData[i].Values[j]; var formula = formulaData.Get(oldRowNo, col); + var type = typeData.Get(oldRowNo, col); + + sheet.Cells.SetCellTypeImpl(new Region(newRowNo, col), type); + if (formula == null) sheet.Cells.SetValueImpl(newRowNo, col, val); else @@ -111,10 +120,10 @@ private int Comparison(RowData x, RowData y) return -1; int comparison = xValue.CompareTo(yValue); - - if(comparison == 0) + + if (comparison == 0) continue; - + comparison = sortOption.Ascending ? comparison : -comparison; return comparison; @@ -130,10 +139,11 @@ public bool Undo(Sheet sheet) var rowCollection = sheet.Cells.GetCellDataStore().GetRowData(_sortedRegion); var formulaCollection = sheet.Cells.GetFormulaStore().GetSubStore(_sortedRegion, false); + var typeCollection = (ConsolidatedDataStore)sheet.Cells.GetTypeStore().GetSubStore(_sortedRegion); sheet.BatchUpdates(); - sheet.Cells.ClearCellsImpl(new[] { _sortedRegion }); + sheet.Cells.GetTypeStore().Clear(_sortedRegion); var rowData = rowCollection.Rows; var rowIndices = rowCollection.RowIndicies; @@ -144,6 +154,7 @@ public bool Undo(Sheet sheet) { var col = rowData[i].ColumnIndices[j]; var formula = formulaCollection.Get(rowIndices[i], col); + var type = typeCollection.Get(rowIndices[i], col); if (formula == null) { var val = rowData[i].Values[j]; @@ -154,9 +165,17 @@ public bool Undo(Sheet sheet) formula.ShiftReferences((newRowNo - rowIndices[i]), 0); sheet.Cells.SetFormulaImpl(newRowNo, col, formula); } + + sheet.Cells.SetCellTypeImpl(new Region(newRowNo, col), type); } } + var restoreData = new CellStoreRestoreData() + { + TypeRestoreData = _typeRestoreData + }; + + sheet.Cells.Restore(restoreData); sheet.EndBatchUpdates(); return true; diff --git a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Types.cs b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Types.cs index 20638ccd..7e844e52 100644 --- a/src/BlazorDatasheet.Core/Data/Cells/CellStore.Types.cs +++ b/src/BlazorDatasheet.Core/Data/Cells/CellStore.Types.cs @@ -20,10 +20,13 @@ public string GetCellType(int row, int col) /// /// /// - internal CellStoreRestoreData SetCellTypeImpl(IRegion region, string type) + internal CellStoreRestoreData SetCellTypeImpl(IRegion region, string? type) { var restoreData = new CellStoreRestoreData(); - restoreData.TypeRestoreData = _typeStore.Add(region, type); + if (string.IsNullOrEmpty(type)) + restoreData.TypeRestoreData = _typeStore.Clear(region); + else + restoreData.TypeRestoreData = _typeStore.Add(region, type); _sheet.MarkDirty(region); return restoreData; } @@ -64,4 +67,6 @@ public void SetType(IEnumerable regions, string type) /// /// public void SetType(int row, int col, string type) => SetType(new Region(row, col), type); + + internal ConsolidatedDataStore GetTypeStore() => _typeStore; } \ No newline at end of file diff --git a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs index 0b359326..1ff7ba2a 100644 --- a/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs +++ b/test/BlazorDatasheet.Test/Commands/SortRangeCommandTests.cs @@ -57,7 +57,7 @@ public void Sort_Col_With_Empty_Rows_Results_In_Continuous_Rows() var region = new ColumnRegion(0, 0); var options = new List { - new ColumnSortOptions(0, true) + new (0, true) }; var cmd = new SortRangeCommand(region, options); @@ -136,9 +136,9 @@ public void Sort_Descending_With_Empty_Cells_Puts_Empty_At_End() public void Sort_Command_Moves_Cell_Types() { var sheet = new Sheet(10, 10); - sheet.Cells[0, 0].Value = 2; - sheet.Cells[1, 0].Value = 1; - sheet.Cells[1, 0].Type = "bool"; + sheet.Cells[1, 0].Value = 2; + sheet.Cells[2, 0].Value = 1; + sheet.Cells[2, 0].Type = "bool"; var cmd = new SortRangeCommand(new ColumnRegion(0)); cmd.Execute(sheet); @@ -146,8 +146,9 @@ public void Sort_Command_Moves_Cell_Types() sheet.Cells[0, 0].Type.Should().Be("bool"); cmd.Undo(sheet); - sheet.Cells[1, 0].Type.Should().Be("bool"); + sheet.Cells[2, 0].Type.Should().Be("bool"); sheet.Cells[0, 0].Type.Should().Be("text"); + sheet.Cells[1, 0].Type.Should().Be("text"); } [Test] From 346b34b86ee909ddaa2b7ab4ce09e87068077a23 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 19 May 2024 16:10:09 +1000 Subject: [PATCH 24/26] Make type restore data readonly --- src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs index 8fa6757a..8dd33f55 100644 --- a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs +++ b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs @@ -12,7 +12,7 @@ public class SortRangeCommand : IUndoableCommand private IRegion? _sortedRegion; private readonly List _sortOptions; private int[] _oldIndices = Array.Empty(); - private RegionRestoreData _typeRestoreData = new(); + private readonly RegionRestoreData _typeRestoreData = new(); /// /// Sorts the specified region on values using the specified sort options. From 522a0571df6096e01f31fc5310bb13f398200e86 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 19 May 2024 16:21:50 +1000 Subject: [PATCH 25/26] Remove benchmarks --- src/Benchmarks/Benchmark/Benchmark.csproj | 18 ------ src/Benchmarks/Benchmark/Program.cs | 73 ----------------------- src/BlazorDatasheet.sln | 7 --- 3 files changed, 98 deletions(-) delete mode 100644 src/Benchmarks/Benchmark/Benchmark.csproj delete mode 100644 src/Benchmarks/Benchmark/Program.cs diff --git a/src/Benchmarks/Benchmark/Benchmark.csproj b/src/Benchmarks/Benchmark/Benchmark.csproj deleted file mode 100644 index b31a2b32..00000000 --- a/src/Benchmarks/Benchmark/Benchmark.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - Exe - net7.0 - enable - enable - - - - - - - - - - - diff --git a/src/Benchmarks/Benchmark/Program.cs b/src/Benchmarks/Benchmark/Program.cs deleted file mode 100644 index 76af2867..00000000 --- a/src/Benchmarks/Benchmark/Program.cs +++ /dev/null @@ -1,73 +0,0 @@ -//benchmark sparselist - -using System; -using System.Collections.Generic; -using System.Linq; -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Jobs; -using BenchmarkDotNet.Running; -using BlazorDatasheet.DataStructures.Store; - -var benchmark = BenchmarkRunner.Run(); - -public class SparseMatrixBenchmark -{ - private IMatrixDataStore _sparseMatrixStoreByRows; - private IMatrixDataStore _sparseMatrixStoreByRow2; - private IMatrixDataStore _sparseMatrixStoreByCol; - - [GlobalSetup] - public void GlobalSetup() - { - _sparseMatrixStoreByRows = new SparseMatrixStoreByRows(); - _sparseMatrixStoreByCol = new SparseMatrixStoreByCols(); - Setup(_sparseMatrixStoreByRows); - Setup(_sparseMatrixStoreByRow2); - Setup(_sparseMatrixStoreByCol); - } - - private void Setup(IMatrixDataStore store) - { - var r = new Random(1); - for (var i = 0; i < 1000; i++) - { - store.Set(r.Next(0, 1000), r.Next(0, 1000), "A"); - } - } - - [Benchmark] - public void Get() - { - _sparseMatrixStoreByRows.Get(100, 100); - } - - [Benchmark] - public void Get2() - { - _sparseMatrixStoreByRow2.Get(100, 100); - } - - [Benchmark] - public void Get3() - { - _sparseMatrixStoreByCol.Get(100, 100); - } - - [Benchmark] - public void Set() - { - _sparseMatrixStoreByRows.Set(100, 100 , "B"); - } - - [Benchmark] - public void Set2() - { - _sparseMatrixStoreByRow2.Set(100, 100, "B"); - } - - [Benchmark] - public void Set3() - { - _sparseMatrixStoreByCol.Set(100, 100, "B"); - } -} \ No newline at end of file diff --git a/src/BlazorDatasheet.sln b/src/BlazorDatasheet.sln index 4a396e66..4b702607 100644 --- a/src/BlazorDatasheet.sln +++ b/src/BlazorDatasheet.sln @@ -20,8 +20,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorDatasheet.Core", "Bla EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorDatasheet.Formula.Functions", "BlazorDatasheet.Formula.Functions\BlazorDatasheet.Formula.Functions.csproj", "{75314586-24CB-4A30-8166-41544ADECEB8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmark", "Benchmarks\Benchmark\Benchmark.csproj", "{A16C6735-6BFB-4484-AAEF-AEAEDA5B068D}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -64,13 +62,8 @@ Global {75314586-24CB-4A30-8166-41544ADECEB8}.Debug|Any CPU.Build.0 = Debug|Any CPU {75314586-24CB-4A30-8166-41544ADECEB8}.Release|Any CPU.ActiveCfg = Release|Any CPU {75314586-24CB-4A30-8166-41544ADECEB8}.Release|Any CPU.Build.0 = Release|Any CPU - {A16C6735-6BFB-4484-AAEF-AEAEDA5B068D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A16C6735-6BFB-4484-AAEF-AEAEDA5B068D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A16C6735-6BFB-4484-AAEF-AEAEDA5B068D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A16C6735-6BFB-4484-AAEF-AEAEDA5B068D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8D866B46-349C-4B1C-B5A6-146DAECCA089} = {88903F7E-79AF-45FD-A777-4E0A101AB65C} - {A16C6735-6BFB-4484-AAEF-AEAEDA5B068D} = {88903F7E-79AF-45FD-A777-4E0A101AB65C} EndGlobalSection EndGlobal From 239084b6a81238272d4bfe523ca4371a181f7f05 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 19 May 2024 16:21:57 +1000 Subject: [PATCH 26/26] Add sorting events --- .../Commands/SortRangeCommand.cs | 28 +++++++++---------- src/BlazorDatasheet.Core/Data/Sheet.cs | 16 +++++++++++ .../Events/BeforeRangeSortEventArgs.cs | 17 +++++++++++ .../RangeSortedEventArgs.cs | 28 +++++++++++++++++++ .../SheetTests/SheetTests.cs | 18 ++++++++++++ 5 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 src/BlazorDatasheet.Core/Events/BeforeRangeSortEventArgs.cs create mode 100644 src/BlazorDatasheet.Core/RangeSortedEventArgs.cs diff --git a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs index 8dd33f55..30e3c325 100644 --- a/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs +++ b/src/BlazorDatasheet.Core/Commands/SortRangeCommand.cs @@ -9,9 +9,9 @@ namespace BlazorDatasheet.Core.Commands; public class SortRangeCommand : IUndoableCommand { private readonly IRegion _region; - private IRegion? _sortedRegion; + public IRegion? SortedRegion; private readonly List _sortOptions; - private int[] _oldIndices = Array.Empty(); + public int[] OldIndices = Array.Empty(); private readonly RegionRestoreData _typeRestoreData = new(); /// @@ -49,10 +49,10 @@ public bool Execute(Sheet sheet) rowData.Sort(rowIndices, Comparison); - _sortedRegion = new Region(_region.Top, _region.Top + rowIndices.Length, _region.Left, _region.Right); - _sortedRegion = _sortedRegion.GetIntersection(sheet.Region); + SortedRegion = new Region(_region.Top, _region.Top + rowIndices.Length, _region.Left, _region.Right); + SortedRegion = SortedRegion.GetIntersection(sheet.Region); - if (_sortedRegion == null) + if (SortedRegion == null) return true; sheet.BatchUpdates(); @@ -96,7 +96,7 @@ public bool Execute(Sheet sheet) sheet.EndBatchUpdates(); - _oldIndices = rowIndices.ToArray(); + OldIndices = rowIndices.ToArray(); return true; } @@ -134,22 +134,22 @@ private int Comparison(RowData x, RowData y) public bool Undo(Sheet sheet) { - if (_sortedRegion == null) + if (SortedRegion == null) return true; - var rowCollection = sheet.Cells.GetCellDataStore().GetRowData(_sortedRegion); - var formulaCollection = sheet.Cells.GetFormulaStore().GetSubStore(_sortedRegion, false); - var typeCollection = (ConsolidatedDataStore)sheet.Cells.GetTypeStore().GetSubStore(_sortedRegion); + var rowCollection = sheet.Cells.GetCellDataStore().GetRowData(SortedRegion); + var formulaCollection = sheet.Cells.GetFormulaStore().GetSubStore(SortedRegion, false); + var typeCollection = (ConsolidatedDataStore)sheet.Cells.GetTypeStore().GetSubStore(SortedRegion); sheet.BatchUpdates(); - sheet.Cells.ClearCellsImpl(new[] { _sortedRegion }); - sheet.Cells.GetTypeStore().Clear(_sortedRegion); + sheet.Cells.ClearCellsImpl(new[] { SortedRegion }); + sheet.Cells.GetTypeStore().Clear(SortedRegion); var rowData = rowCollection.Rows; var rowIndices = rowCollection.RowIndicies; - for (int i = 0; i < _oldIndices.Length; i++) + for (int i = 0; i < OldIndices.Length; i++) { - var newRowNo = _oldIndices[i]; + var newRowNo = OldIndices[i]; for (int j = 0; j < rowData[i].ColumnIndices.Length; j++) { var col = rowData[i].ColumnIndices[j]; diff --git a/src/BlazorDatasheet.Core/Data/Sheet.cs b/src/BlazorDatasheet.Core/Data/Sheet.cs index f1c073bd..e5e57027 100644 --- a/src/BlazorDatasheet.Core/Data/Sheet.cs +++ b/src/BlazorDatasheet.Core/Data/Sheet.cs @@ -3,6 +3,7 @@ using BlazorDatasheet.Core.Commands; using BlazorDatasheet.Core.Data.Cells; using BlazorDatasheet.Core.Edit; +using BlazorDatasheet.Core.Events; using BlazorDatasheet.Core.Events.Visual; using BlazorDatasheet.Core.Formats; using BlazorDatasheet.Core.Interfaces; @@ -85,6 +86,10 @@ public class Sheet /// public event EventHandler? SheetDirty; + public event EventHandler? BeforeRangeSort; + + public event EventHandler? RangeSorted; + #endregion /// @@ -301,6 +306,17 @@ public void BatchUpdates() _isBatchingChanges = true; } + public void SortRange(IRegion region, List? sortOptions = null) + { + var beforeArgs = new BeforeRangeSortEventArgs(region, sortOptions); + BeforeRangeSort?.Invoke(this, beforeArgs); + var cmd = new SortRangeCommand(region, sortOptions); + if (!beforeArgs.Cancel) + Commands.ExecuteCommand(cmd); + var afterArgs = new RangeSortedEventArgs(region, cmd.SortedRegion, cmd.OldIndices); + RangeSorted?.Invoke(this, afterArgs); + } + /// /// Ends the batching of dirty cells and regions, and emits the dirty sheet event. /// diff --git a/src/BlazorDatasheet.Core/Events/BeforeRangeSortEventArgs.cs b/src/BlazorDatasheet.Core/Events/BeforeRangeSortEventArgs.cs new file mode 100644 index 00000000..5e15c6a2 --- /dev/null +++ b/src/BlazorDatasheet.Core/Events/BeforeRangeSortEventArgs.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; +using BlazorDatasheet.Core.Commands; +using BlazorDatasheet.DataStructures.Geometry; + +namespace BlazorDatasheet.Core.Events; + +public class BeforeRangeSortEventArgs : CancelEventArgs +{ + public IRegion Region { get; } + public IList? SortOptions { get; } + + public BeforeRangeSortEventArgs(IRegion region, IList? sortOptions) + { + Region = region; + SortOptions = sortOptions; + } +} \ No newline at end of file diff --git a/src/BlazorDatasheet.Core/RangeSortedEventArgs.cs b/src/BlazorDatasheet.Core/RangeSortedEventArgs.cs new file mode 100644 index 00000000..ae8223cf --- /dev/null +++ b/src/BlazorDatasheet.Core/RangeSortedEventArgs.cs @@ -0,0 +1,28 @@ +using BlazorDatasheet.DataStructures.Geometry; + +namespace BlazorDatasheet.Core; + +public class RangeSortedEventArgs +{ + /// + /// The region that was requested to be sorted. + /// + public IRegion? Region { get; } + + /// + /// The region that was sorted. + /// + public IRegion? SortedRegion { get; } + + /// + /// Indices of the rows within sortedRegion before sorting. + /// + public IList OldIndicies { get; set; } + + public RangeSortedEventArgs(IRegion? region, IRegion? sortedRegion, IList oldIndicies) + { + Region = region; + SortedRegion = sortedRegion; + OldIndicies = oldIndicies; + } +} \ No newline at end of file diff --git a/test/BlazorDatasheet.Test/SheetTests/SheetTests.cs b/test/BlazorDatasheet.Test/SheetTests/SheetTests.cs index 94a8cf73..6d7ac660 100644 --- a/test/BlazorDatasheet.Test/SheetTests/SheetTests.cs +++ b/test/BlazorDatasheet.Test/SheetTests/SheetTests.cs @@ -129,4 +129,22 @@ public void Cell_Changes_Emits_Cell_ChangeEvent() posnsChanged.First().col.Should().Be(1); posnsChanged.First().row.Should().Be(1); } + + [Test] + public void Cancel_Before_Range_Sort_Cancels_Sorting() + { + var sheet = new Sheet(10, 10); + sheet.Range("A1")!.Value = 2; + sheet.Range("A2")!.Value = 1; + + // disable default sort + sheet.BeforeRangeSort += (sender, args) => + { + args.Cancel = true; + }; + + sheet.SortRange(new ColumnRegion(0)); + sheet.Cells[0, 0].Value.Should().Be(2); + sheet.Cells[1, 0].Value.Should().Be(1); + } } \ No newline at end of file