From 5504b7529931edc5027c9931fad6973277a4fe6b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 19:28:33 +0000 Subject: [PATCH] [patch] Break natural-order ties ordinally so distinct strings never compare equal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NaturalStringComparer.Compare returned 0 when every chunk matched, so strings that differ only in leading zeros or digit script ("file5" and "file005", "v1" and "v01", "file٥" and "file5") compared equal. Sorted collections then dropped one of them or threw on Add, and Array.Sort's result depended on the input order. The natural order stays the primary key; when it ties, the strings are now ordered by string.CompareOrdinal, so Compare returns 0 only for ordinally equal strings. The tests that asserted 0 for such pairs now assert a nonzero, antisymmetric result. Fixes #59 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013JBDCsjuRez5zdzBcBJ7Y7 --- Sorting.Test/NaturalStringComparerTests.cs | 93 +++++++++++++++++++--- Sorting/NaturalStringComparer.cs | 13 ++- 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/Sorting.Test/NaturalStringComparerTests.cs b/Sorting.Test/NaturalStringComparerTests.cs index 3d0a2ec..9d235a6 100644 --- a/Sorting.Test/NaturalStringComparerTests.cs +++ b/Sorting.Test/NaturalStringComparerTests.cs @@ -97,10 +97,85 @@ public void Compare_MixedAlphaNumeric_SortsNaturally() public void Compare_StringsWithLeadingZeros_HandledCorrectly() { // Numbers with leading zeros should be treated as numeric values - Assert.AreEqual(0, _comparer.Compare("file005", "file5")); // Numerically equal + AssertTiedOnlyByOrdinal("file005", "file5"); // Numerically equal, but distinct strings Assert.IsLessThan(0, _comparer.Compare("file005", "file06")); // 5 < 6 numerically } + [TestMethod] + public void Compare_NumericallyEqualStrings_AreNotEqual() + { + // Only ordinally equal strings may compare as zero, or sorted collections treat them as one key + string[] values = ["file5", "file05", "file005", "file٥", "v1", "v01", "file5", "a"]; + foreach (string x in values) + { + foreach (string y in values) + { + Assert.AreEqual(string.Equals(x, y, StringComparison.Ordinal), _comparer.Compare(x, y) == 0, $"Compare(\"{x}\", \"{y}\")"); + } + } + } + + [TestMethod] + public void Compare_NumericallyEqualStrings_StayBetweenTheirNeighbours() + { + // The tie-break only orders numerically equal strings among themselves + foreach (string tied in new[] { "file5", "file05", "file005", "file٥" }) + { + Assert.IsLessThan(0, _comparer.Compare("file4", tied)); + Assert.IsGreaterThan(0, _comparer.Compare("file6", tied)); + } + } + + [TestMethod] + public void SortedSet_KeepsStringsThatDifferOnlyInLeadingZeros() + { + SortedSet set = new(_comparer) { "file5", "file05", "file005" }; + + Assert.HasCount(3, set); + } + + [TestMethod] + public void SortedDictionary_AcceptsKeysThatDifferOnlyInLeadingZeros() + { + SortedDictionary dictionary = new(_comparer) + { + ["v1"] = 1, + }; + dictionary.Add("v01", 2); + + Assert.HasCount(2, dictionary); + } + + [TestMethod] + public void Sort_IsTheSameForEveryInputOrder() + { + string[][] permutations = + [ + ["a", "b5", "b05"], + ["a", "b05", "b5"], + ["b5", "a", "b05"], + ["b5", "b05", "a"], + ["b05", "a", "b5"], + ["b05", "b5", "a"], + ]; + + string[] expected = [.. permutations[0].OrderBy(s => s, _comparer)]; + foreach (string[] permutation in permutations) + { + Array.Sort(permutation, _comparer); + CollectionAssert.AreEqual(expected, permutation); + } + } + + private void AssertTiedOnlyByOrdinal(string x, string y) + { + // Numerically equal but distinct strings compare by ordinal, never as equal + int expected = Math.Sign(string.CompareOrdinal(x, y)); + Assert.AreNotEqual(0, expected); + Assert.AreEqual(expected, Math.Sign(_comparer.Compare(x, y)), $"Compare(\"{x}\", \"{y}\")"); + Assert.AreEqual(-expected, Math.Sign(_comparer.Compare(y, x)), $"Compare(\"{y}\", \"{x}\")"); + } + [TestMethod] public void Compare_NonAsciiDigits_ComparedByNumericValue() { @@ -116,22 +191,22 @@ public void Compare_NonAsciiDigits_ComparedByNumericValue() } [TestMethod] - public void Compare_NonAsciiDigits_EqualValuesAreEqual() + public void Compare_NonAsciiDigits_EqualValuesAreTiedOrdinally() { // Arabic-Indic five and ASCII five spell the same number - Assert.AreEqual(0, _comparer.Compare("٥", "5")); - Assert.AreEqual(0, _comparer.Compare("file٥", "file5")); + AssertTiedOnlyByOrdinal("٥", "5"); + AssertTiedOnlyByOrdinal("file٥", "file5"); } [TestMethod] public void Compare_NonAsciiLeadingZeros_NormalizedLikeAsciiZeros() { // An Arabic-Indic zero is a leading zero, so both chunks reduce to the single digit 0 - Assert.AreEqual(0, _comparer.Compare("٠0", "0")); - Assert.AreEqual(0, _comparer.Compare("٠٥", "5")); + AssertTiedOnlyByOrdinal("٠0", "0"); + AssertTiedOnlyByOrdinal("٠٥", "5"); // An all-zeros chunk compares as zero, whatever the script - Assert.AreEqual(0, _comparer.Compare("٠٠", "0")); + AssertTiedOnlyByOrdinal("٠٠", "0"); Assert.IsLessThan(0, _comparer.Compare("٠٠", "1")); } @@ -140,7 +215,7 @@ public void Compare_MixedScriptDigits_ComparedByNumericValue() { // A single chunk may mix scripts; it still spells one number Assert.IsLessThan(0, _comparer.Compare("file1٥", "file20")); // 15 < 20 - Assert.AreEqual(0, _comparer.Compare("file1٥", "file15")); + AssertTiedOnlyByOrdinal("file1٥", "file15"); } [TestMethod] @@ -295,7 +370,7 @@ public void Compare_DigitsOutsideTheBasicMultilingualPlane_ComparedByNumericValu Assert.IsLessThan(0, _comparer.Compare("file\U0001D7D7", "file\U0001D7CF\U0001D7CE")); Assert.IsLessThan(0, _comparer.Compare("file\U0001D7D7", "file10")); Assert.IsGreaterThan(0, _comparer.Compare("file\U0001D7D7", "file8")); - Assert.AreEqual(0, _comparer.Compare("file\U0001D7D7", "file9")); + AssertTiedOnlyByOrdinal("file\U0001D7D7", "file9"); } [TestMethod] diff --git a/Sorting/NaturalStringComparer.cs b/Sorting/NaturalStringComparer.cs index b4dad2b..31f24f7 100644 --- a/Sorting/NaturalStringComparer.cs +++ b/Sorting/NaturalStringComparer.cs @@ -22,7 +22,8 @@ public partial class NaturalStringComparer : IComparer /// Second string to compare. /// /// Less than zero if x is less than y. - /// Zero if x equals y. + /// Zero only if x and y are ordinally equal; strings whose numbers differ only in leading zeros or + /// digit script are ordered next to each other, ordinally. /// Greater than zero if x is greater than y. /// public int Compare(string? x, string? y) @@ -91,7 +92,15 @@ public int Compare(string? x, string? y) } // If we've exhausted one sequence but not the other, the shorter one comes first - return xIndex < x.Length ? 1 : yIndex < y.Length ? -1 : 0; + if (xIndex < x.Length || yIndex < y.Length) + { + return xIndex < x.Length ? 1 : -1; + } + + // Every chunk matched, but the strings differ in leading zeros or digit script ("file5" and + // "file005"). Break the tie ordinally so distinct strings never compare equal, which sorted + // collections would take to mean the same key. + return string.CompareOrdinal(x, y); } ///