From ba332e70b3ee27f7e31988c121d9319ccf8b8431 Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Thu, 8 Oct 2026 22:33:43 +0000 Subject: [PATCH] Break StandardOrder.Compare ties between case variants ordinally [patch] Compare returned 0 for "Title"/"title" and "MyKey"/"mykey", so a SortedDictionary built on it threw and a SortedSet dropped keys. When the standard-order comparison ties on two different strings, fall back to an ordinal comparison, so 0 means the same string. Fixes #197 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019MupvoiQ9WM1vUJoCzYJ6W --- .../StandardOrderCompareTotalOrderTests.cs | 66 +++++++++++++++++++ Frontmatter/StandardOrder.cs | 12 +++- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 Frontmatter.Test/StandardOrderCompareTotalOrderTests.cs diff --git a/Frontmatter.Test/StandardOrderCompareTotalOrderTests.cs b/Frontmatter.Test/StandardOrderCompareTotalOrderTests.cs new file mode 100644 index 0000000..362c60a --- /dev/null +++ b/Frontmatter.Test/StandardOrderCompareTotalOrderTests.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Frontmatter.Test; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Names that differ only in case must not compare as equal, or a sorted collection +/// built on throws or drops keys (#197). +/// +[TestClass] +public class StandardOrderCompareTotalOrderTests +{ + private static readonly Comparer Comparer = Comparer.Create(StandardOrder.Compare); + + [TestMethod] + [DataRow("Title", "title")] + [DataRow("MyKey", "mykey")] + [DataRow("TAGS", "Tags")] + public void Compare_CaseVariants_AreNotEqualAndAntisymmetric(string a, string b) + { + int forward = StandardOrder.Compare(a, b); + int backward = StandardOrder.Compare(b, a); + + Assert.AreNotEqual(0, forward, $"'{a}' and '{b}' are different strings and must not compare equal"); + Assert.AreEqual(-Math.Sign(forward), Math.Sign(backward), "Compare must be antisymmetric"); + } + + [TestMethod] + public void Compare_SameString_IsZero() + { + Assert.AreEqual(0, StandardOrder.Compare("Title", "Title")); + Assert.AreEqual(0, StandardOrder.Compare("mykey", "mykey")); + } + + [TestMethod] + public void SortedDictionary_KeepsCaseVariantKeys() + { + SortedDictionary dictionary = new(Comparer) + { + { "title", "a" }, + { "Title", "b" }, + }; + + Assert.HasCount(2, dictionary); + Assert.AreEqual("a", dictionary["title"]); + Assert.AreEqual("b", dictionary["Title"]); + } + + [TestMethod] + public void SortedSet_KeepsCaseVariantKeys() + { + SortedSet set = new(["tags", "Tags", "myKey", "MyKey"], Comparer); + + Assert.HasCount(4, set); + } + + [TestMethod] + public void Compare_OrderAcrossPositionsIsUnchanged() + { + Assert.IsLessThan(0, StandardOrder.Compare("Title", "author"), "Known keys keep their standard order regardless of case"); + Assert.IsLessThan(0, StandardOrder.Compare("Tags", "zzz_unknown"), "Known keys come before unknown keys"); + Assert.IsLessThan(0, StandardOrder.Compare("Alpha", "beta"), "Unknown keys still sort case-insensitively"); + Assert.IsGreaterThan(0, StandardOrder.Compare("zzz_unknown", "TITLE"), "Unknown keys come after known keys"); + } +} diff --git a/Frontmatter/StandardOrder.cs b/Frontmatter/StandardOrder.cs index a086b10..5dd6668 100644 --- a/Frontmatter/StandardOrder.cs +++ b/Frontmatter/StandardOrder.cs @@ -203,9 +203,13 @@ public static class StandardOrder /// The second property name. /// /// A negative value if 'a' should appear before 'b', - /// zero if they are equal in order, + /// zero only if 'a' and 'b' are the same string, /// a positive value if 'a' should appear after 'b'. /// + /// + /// Names that differ only in case share a position, so they are ordered against each other ordinally. + /// That keeps the comparison a total order, so it can back a sorted collection without losing keys. + /// public static int Compare(string a, string b) { Ensure.NotNull(a); @@ -216,6 +220,12 @@ public static int Compare(string a, string b) return 0; } + int result = CompareByPosition(a, b); + return result != 0 ? result : string.CompareOrdinal(a, b); + } + + private static int CompareByPosition(string a, string b) + { // Get the indices from the standard property list int indexA = Array.IndexOf(PropertyNames, a.ToLowerInvariant()); int indexB = Array.IndexOf(PropertyNames, b.ToLowerInvariant());