diff --git a/YKanBan.Tests/Models/CardOrderingTests.cs b/YKanBan.Tests/Models/CardOrderingTests.cs new file mode 100644 index 0000000..afae657 --- /dev/null +++ b/YKanBan.Tests/Models/CardOrderingTests.cs @@ -0,0 +1,46 @@ +using YKanBan.Models; +using YKanBan.Storage.AppData; + +namespace YKanBan.Tests.Models; + +/// +/// Tests for the in-column card display order: each sort option plus the id +/// ascending tie-breaker. +/// +[TestClass] +public class CardOrderingTests +{ + private static CardModel Card(long id, string title = "", long updatedAt = 0) => + new(id, 1, title, string.Empty, 0, updatedAt, []); + + private static long[] Ids(IEnumerable cards, CardSortOption option) => + CardOrdering.Sort(cards, option).Select(card => card.Id).ToArray(); + + [TestMethod] + public void IdSortsAscending() + { + CollectionAssert.AreEqual(new[] { 1L, 2L, 3L }, Ids([Card(3), Card(1), Card(2)], CardSortOption.Id)); + } + + [TestMethod] + public void UpdatedAtSortsMostRecentFirstThenById() + { + CardModel[] cards = [Card(1, updatedAt: 10), Card(2, updatedAt: 30), Card(3, updatedAt: 10), Card(4, updatedAt: 20)]; + CollectionAssert.AreEqual(new[] { 2L, 4L, 1L, 3L }, Ids(cards, CardSortOption.UpdatedAt)); + } + + [TestMethod] + public void TitleSortsByCodePointWithEmptyFirstThenById() + { + CardModel[] cards = [Card(1, "b"), Card(2, "B"), Card(3, ""), Card(4, "a"), Card(5, "b")]; + CollectionAssert.AreEqual(new[] { 3L, 2L, 4L, 1L, 5L }, Ids(cards, CardSortOption.Title)); + } + + [TestMethod] + public void TitleSortsSupplementaryCharactersAfterBmpPrivateUse() + { + // U+1F600 is a surrogate pair in UTF-16, so ordinal comparison would put it before U+E000. + CardModel[] cards = [Card(1, "\U0001F600"), Card(2, "")]; + CollectionAssert.AreEqual(new[] { 2L, 1L }, Ids(cards, CardSortOption.Title)); + } +} diff --git a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs index 55b1915..3c06d46 100644 --- a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs +++ b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs @@ -145,7 +145,7 @@ public class AppConfigStoreTests { Language = "zh-Hans", Theme = ThemeOption.Light, - Sort = new SortSettings { Card = CardSortOption.CreatedAt }, + Sort = new SortSettings { Card = CardSortOption.Title }, ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Wide }, Confirmations = new ConfirmationSettings { DiscardEdit = false }, }; diff --git a/YKanBan/Models/CardOrdering.cs b/YKanBan/Models/CardOrdering.cs new file mode 100644 index 0000000..fa9b7fd --- /dev/null +++ b/YKanBan/Models/CardOrdering.cs @@ -0,0 +1,70 @@ +using System.Text; +using YKanBan.Storage.AppData; + +namespace YKanBan.Models; + +/// +/// Computes the in-column card display order for a . +/// Columns are not affected: they always stay in id order. Every option falls +/// back to id ascending as the final tie-breaker, so the order is total. +/// +public static class CardOrdering +{ + /// + /// Orders cards for display. + /// + /// The cards to order, typically those of one column. + /// The selected sort option. + /// The cards in display order. + public static IReadOnlyList Sort(IEnumerable cards, CardSortOption option) + { + IOrderedEnumerable ordered = option switch + { + CardSortOption.Id => cards.OrderBy(card => card.Id), + + // Most recently modified first. + CardSortOption.UpdatedAt => cards.OrderByDescending(card => card.UpdatedAt).ThenBy(card => card.Id), + + // Code point order, matching SQLite's BINARY collation; the empty title sorts first. + CardSortOption.Title => cards.OrderBy(card => card.Title, CodePointComparer.Instance).ThenBy(card => card.Id), + + _ => throw new ArgumentOutOfRangeException(nameof(option), option, "Unknown card sort option."), + }; + return ordered.ToList(); + } + + /// + /// Compares strings by Unicode code points. Unlike ordinal UTF-16 comparison + /// it orders supplementary characters after U+E000–U+FFFF, as BINARY does on UTF-8. + /// + private sealed class CodePointComparer : IComparer + { + public static readonly CodePointComparer Instance = new(); + + public int Compare(string? x, string? y) + { + if (x is null || y is null) + { + return x is null ? (y is null ? 0 : -1) : 1; + } + + StringRuneEnumerator left = x.EnumerateRunes(); + StringRuneEnumerator right = y.EnumerateRunes(); + while (true) + { + bool hasLeft = left.MoveNext(); + bool hasRight = right.MoveNext(); + if (!hasLeft || !hasRight) + { + return hasLeft.CompareTo(hasRight); + } + + int result = left.Current.Value.CompareTo(right.Current.Value); + if (result != 0) + { + return result; + } + } + } + } +} diff --git a/YKanBan/Storage/AppData/AppConfig.cs b/YKanBan/Storage/AppData/AppConfig.cs index 4b1ed52..65beb6a 100644 --- a/YKanBan/Storage/AppData/AppConfig.cs +++ b/YKanBan/Storage/AppData/AppConfig.cs @@ -13,10 +13,6 @@ public enum CardSortOption [EnumMember(Value = "id")] Id, - /// Creation time, oldest first. - [EnumMember(Value = "created-at")] - CreatedAt, - /// Last modification time, most recently modified first. [EnumMember(Value = "updated-at")] UpdatedAt,