Files
radzen-blazor/Radzen.Blazor.Tests/Spreadsheet/MultiKeySortContractTests.cs
Atanas Korchev 52e1d29585 Add multi-key sort, advanced filter criteria, per-column AutoFilter API
Three independent additions to the Spreadsheet API, motivated by the
gap analysis against Excel COM, Office.js, Aspose, and Syncfusion:

Multi-key sort (Pass 3)

- New SortKey class: ColumnIndex (relative to the sort range), Order,
  SortOn, CustomList, CustomColor, CaseSensitive.
- New SortOn enum: Values, CellColor, FontColor.
- Worksheet.Sort(range, params SortKey[]) — stable, supports multiple
  sort levels, custom-list ordering, case sensitivity, sort by color.
- Table.Sort(params SortKey[]) overload using table-relative indices.
- Existing single-key Sort(range, order, keyIndex) retained for
  back-compat; redirects to the new API.
- Empty cells always sort to the bottom regardless of direction
  (matches Excel and pre-existing test).
- Cell.Clone now preserves Format (was dropped on the floor; the bug
  was masked because no caller of Clone read Format until color sort).

Advanced filter criteria (Pass 2A)

- TopFilterCriterion — Top/Bottom N items, by count or percent.
- DynamicFilterCriterion — full DynamicFilterType enum with 34 values
  covering above/below average, today/yesterday/tomorrow, this/last/
  next week/month/quarter/year, year-to-date, individual months, and
  Q1..Q4.
- CellColorFilterCriterion — match cells by background or font color.
- New OnApply(sheet, range) hook on FilterCriterion lets distribution-
  aware criteria (Top, AboveAverage) compute thresholds before the
  per-row Matches loop. Default no-op preserves existing behavior.
- Visitor extended with three new Visit overloads + base no-op
  defaults.

Per-column AutoFilter API (Pass 2B)

- AutoFilter.ApplyValueFilter(col, values), ApplyCustomFilter(col,
  criterion), ApplyTopFilter(col, count, percent, bottom),
  ApplyDynamicFilter(col, type), ApplyColorFilter(col, color,
  fontColor), ClearColumnFilter(col), GetColumnFilter(col).
- Column indices on these methods are RELATIVE to the AutoFilter
  range, matching the convention used by SortKey and Office.js
  TableSort.

Tests

- MultiKeySortContractTests: 11 facts (multi-key, custom list,
  case-sensitive, color sort, range bounds, no-op edges).
- FilterCriterionContractTests: 11 facts (Top/Bottom by count and
  percent, AboveAverage, Today, ThisMonth, Quarter1, color filters,
  visitor dispatch).
- AutoFilterPerColumnTests: 12 facts (each Apply* method, replace
  semantics, per-column clear, validation errors).

3,706 tests pass.
2026-06-15 18:45:11 +03:00

228 lines
8.9 KiB
C#

using Radzen;
using Radzen.Documents.Spreadsheet;
using Xunit;
namespace Radzen.Blazor.Spreadsheet.Tests;
#nullable enable
// Contract for the multi-key sort API: SortKey + SortOn enum + new
// Worksheet.Sort(range, params SortKey[]) overload. Column indices in
// SortKey are relative to the range's left edge.
public class MultiKeySortContractTests
{
private static (Workbook wb, Worksheet ws) Build(int rows, int cols)
{
var wb = new Workbook();
var ws = wb.AddSheet("Sheet1", rows, cols);
return (wb, ws);
}
private static RangeRef Range(int r1, int c1, int r2, int c2) =>
new(new CellRef(r1, c1), new CellRef(r2, c2));
// ── SortKey shape ───────────────────────────────────────────────────────
[Fact]
public void SortKey_DefaultsAreSensible()
{
var key = new SortKey { ColumnIndex = 0 };
Assert.Equal(SortOrder.Ascending, key.Order);
Assert.Equal(SortOn.Values, key.SortOn);
Assert.False(key.CaseSensitive);
Assert.Null(key.CustomList);
}
// ── Multi-key sort ──────────────────────────────────────────────────────
[Fact]
public void Sort_TwoKeys_ShouldUseSecondaryWhenPrimaryEquals()
{
// Region asc, then Q1 desc
var (_, ws) = Build(4, 2);
ws.Cells[0, 0].SetValue("EMEA"); ws.Cells[0, 1].Value = 100.0;
ws.Cells[1, 0].SetValue("AMER"); ws.Cells[1, 1].Value = 50.0;
ws.Cells[2, 0].SetValue("EMEA"); ws.Cells[2, 1].Value = 200.0;
ws.Cells[3, 0].SetValue("AMER"); ws.Cells[3, 1].Value = 75.0;
ws.Sort(Range(0, 0, 3, 1),
new SortKey { ColumnIndex = 0, Order = SortOrder.Ascending },
new SortKey { ColumnIndex = 1, Order = SortOrder.Descending });
// Expected order: AMER 75, AMER 50, EMEA 200, EMEA 100
Assert.Equal("AMER", ws.Cells[0, 0].Value); Assert.Equal(75.0, ws.Cells[0, 1].Value);
Assert.Equal("AMER", ws.Cells[1, 0].Value); Assert.Equal(50.0, ws.Cells[1, 1].Value);
Assert.Equal("EMEA", ws.Cells[2, 0].Value); Assert.Equal(200.0, ws.Cells[2, 1].Value);
Assert.Equal("EMEA", ws.Cells[3, 0].Value); Assert.Equal(100.0, ws.Cells[3, 1].Value);
}
[Fact]
public void Sort_SingleKey_ShouldBeRelativeToRange()
{
// Sort B1:D3 by column index 0 (which is sheet column B, the FIRST column of the range)
var (_, ws) = Build(3, 4);
// A column is unrelated (left of range)
ws.Cells[0, 0].SetValue("a"); ws.Cells[1, 0].SetValue("b"); ws.Cells[2, 0].SetValue("c");
// B column = sort key
ws.Cells[0, 1].Value = 30.0;
ws.Cells[1, 1].Value = 10.0;
ws.Cells[2, 1].Value = 20.0;
ws.Cells[0, 2].SetValue("X"); ws.Cells[1, 2].SetValue("Y"); ws.Cells[2, 2].SetValue("Z");
ws.Sort(Range(0, 1, 2, 3), new SortKey { ColumnIndex = 0 });
// A column untouched
Assert.Equal("a", ws.Cells[0, 0].Value);
// B column sorted ascending: 10, 20, 30
Assert.Equal(10.0, ws.Cells[0, 1].Value);
Assert.Equal(20.0, ws.Cells[1, 1].Value);
Assert.Equal(30.0, ws.Cells[2, 1].Value);
// C column moves with B
Assert.Equal("Y", ws.Cells[0, 2].Value);
Assert.Equal("Z", ws.Cells[1, 2].Value);
Assert.Equal("X", ws.Cells[2, 2].Value);
}
// ── Custom list ─────────────────────────────────────────────────────────
[Fact]
public void Sort_WithCustomList_ShouldUseListOrder()
{
var (_, ws) = Build(4, 1);
ws.Cells[0, 0].SetValue("Mar");
ws.Cells[1, 0].SetValue("Jan");
ws.Cells[2, 0].SetValue("Feb");
ws.Cells[3, 0].SetValue("Apr");
ws.Sort(Range(0, 0, 3, 0),
new SortKey { ColumnIndex = 0, CustomList = ["Jan", "Feb", "Mar", "Apr"] });
Assert.Equal("Jan", ws.Cells[0, 0].Value);
Assert.Equal("Feb", ws.Cells[1, 0].Value);
Assert.Equal("Mar", ws.Cells[2, 0].Value);
Assert.Equal("Apr", ws.Cells[3, 0].Value);
}
[Fact]
public void Sort_WithCustomList_DescendingShouldReverse()
{
var (_, ws) = Build(3, 1);
ws.Cells[0, 0].SetValue("Feb");
ws.Cells[1, 0].SetValue("Jan");
ws.Cells[2, 0].SetValue("Mar");
ws.Sort(Range(0, 0, 2, 0),
new SortKey
{
ColumnIndex = 0,
Order = SortOrder.Descending,
CustomList = ["Jan", "Feb", "Mar"],
});
Assert.Equal("Mar", ws.Cells[0, 0].Value);
Assert.Equal("Feb", ws.Cells[1, 0].Value);
Assert.Equal("Jan", ws.Cells[2, 0].Value);
}
[Fact]
public void Sort_WithCustomList_ValuesNotInListShouldComeAfter()
{
var (_, ws) = Build(4, 1);
ws.Cells[0, 0].SetValue("Apple");
ws.Cells[1, 0].SetValue("Jan");
ws.Cells[2, 0].SetValue("Banana");
ws.Cells[3, 0].SetValue("Feb");
ws.Sort(Range(0, 0, 3, 0),
new SortKey { ColumnIndex = 0, CustomList = ["Jan", "Feb"] });
// Items in the list come first, in list order
Assert.Equal("Jan", ws.Cells[0, 0].Value);
Assert.Equal("Feb", ws.Cells[1, 0].Value);
// Items not in the list follow, alphabetically
Assert.Equal("Apple", ws.Cells[2, 0].Value);
Assert.Equal("Banana", ws.Cells[3, 0].Value);
}
// ── Case sensitivity ────────────────────────────────────────────────────
[Fact]
public void Sort_CaseInsensitive_ShouldTreatVariantsAsEqual_AndPreserveOrder()
{
var (_, ws) = Build(3, 2);
ws.Cells[0, 0].SetValue("apple"); ws.Cells[0, 1].Value = 1.0;
ws.Cells[1, 0].SetValue("Apple"); ws.Cells[1, 1].Value = 2.0;
ws.Cells[2, 0].SetValue("APPLE"); ws.Cells[2, 1].Value = 3.0;
ws.Sort(Range(0, 0, 2, 1), new SortKey { ColumnIndex = 0 });
// Stable: original order preserved when keys are equal
Assert.Equal(1.0, ws.Cells[0, 1].Value);
Assert.Equal(2.0, ws.Cells[1, 1].Value);
Assert.Equal(3.0, ws.Cells[2, 1].Value);
}
[Fact]
public void Sort_CaseSensitive_ShouldOrderUppercaseSeparately()
{
var (_, ws) = Build(3, 1);
ws.Cells[0, 0].SetValue("apple");
ws.Cells[1, 0].SetValue("Apple");
ws.Cells[2, 0].SetValue("APPLE");
ws.Sort(Range(0, 0, 2, 0),
new SortKey { ColumnIndex = 0, CaseSensitive = true });
// Ordinal sort: 'A' (0x41) < 'a' (0x61), so APPLE < Apple < apple.
Assert.Equal("APPLE", ws.Cells[0, 0].Value);
Assert.Equal("Apple", ws.Cells[1, 0].Value);
Assert.Equal("apple", ws.Cells[2, 0].Value);
}
// ── Sort by color ───────────────────────────────────────────────────────
[Fact]
public void Sort_ByCellColor_ShouldGroupMatchingColorFirst()
{
var (_, ws) = Build(4, 1);
ws.Cells[0, 0].SetValue("a");
ws.Cells[1, 0].SetValue("b"); ws.Cells[1, 0].Format = new Format { BackgroundColor = "#FFFF00" };
ws.Cells[2, 0].SetValue("c");
ws.Cells[3, 0].SetValue("d"); ws.Cells[3, 0].Format = new Format { BackgroundColor = "#FFFF00" };
ws.Sort(Range(0, 0, 3, 0),
new SortKey
{
ColumnIndex = 0,
SortOn = SortOn.CellColor,
CustomColor = "#FFFF00",
Order = SortOrder.Ascending,
});
// Yellow rows come first (preserving their relative order).
Assert.Equal("b", ws.Cells[0, 0].Value);
Assert.Equal("d", ws.Cells[1, 0].Value);
Assert.Equal("a", ws.Cells[2, 0].Value);
Assert.Equal("c", ws.Cells[3, 0].Value);
}
// ── Empty + no-op cases ─────────────────────────────────────────────────
[Fact]
public void Sort_EmptyKeys_ShouldBeNoOp()
{
var (_, ws) = Build(3, 1);
ws.Cells[0, 0].Value = 3.0;
ws.Cells[1, 0].Value = 1.0;
ws.Cells[2, 0].Value = 2.0;
ws.Sort(Range(0, 0, 2, 0)); // params, no keys
// Order unchanged
Assert.Equal(3.0, ws.Cells[0, 0].Value);
Assert.Equal(1.0, ws.Cells[1, 0].Value);
Assert.Equal(2.0, ws.Cells[2, 0].Value);
}
[Fact]
public void Sort_KeyOutsideRange_ShouldThrow()
{
var (_, ws) = Build(3, 2);
Assert.Throws<System.ArgumentOutOfRangeException>(() =>
ws.Sort(Range(0, 0, 2, 1), new SortKey { ColumnIndex = 5 }));
}
}