diff --git a/Semantics.Paths/SemanticPath.cs b/Semantics.Paths/SemanticPath.cs index 46d789ed..3d7a6bb3 100644 --- a/Semantics.Paths/SemanticPath.cs +++ b/Semantics.Paths/SemanticPath.cs @@ -82,8 +82,13 @@ public abstract record SemanticPath : SemanticString, ICompa /// sort an collection through the generic /// comparison path instead of boxing into the non-generic /// . + /// + /// Ordering is ordinal, so that sorting a path collection through this generic path agrees with + /// the inherited rather than varying with + /// the running culture. + /// /// - int IComparable.CompareTo(IPath? other) => WeakString.CompareTo(strB: other?.WeakString); + int IComparable.CompareTo(IPath? other) => string.CompareOrdinal(WeakString, other?.WeakString); /// /// Normalizes the path by standardizing directory separators and removing trailing separators. diff --git a/Semantics.Strings/SemanticString.cs b/Semantics.Strings/SemanticString.cs index 158cf9f7..f29de235 100644 --- a/Semantics.Strings/SemanticString.cs +++ b/Semantics.Strings/SemanticString.cs @@ -92,15 +92,25 @@ public TDest As() public char this[int index] => WeakString[index: index]; /// + /// + /// Ordering is ordinal, matching the ordinal and + /// the record generates from . A + /// culture-sensitive comparison would give punctuation minimal collation weight, so values such as + /// "Co-op" and "Coop" would compare equal while remaining unequal and differently + /// hashed — which breaks the / consistency + /// contract and lets a discard a distinct + /// value as a duplicate. It would also make the sort order depend on the running culture. + /// public int CompareTo(object? value) => value switch { null => 1, - ISemanticString semanticString => WeakString.CompareTo(strB: semanticString.WeakString), - string stringValue => WeakString.CompareTo(strB: stringValue), + ISemanticString semanticString => string.CompareOrdinal(WeakString, semanticString.WeakString), + string stringValue => string.CompareOrdinal(WeakString, stringValue), _ => throw new ArgumentException($"Object must be of type {nameof(String)} or {nameof(ISemanticString)}.", nameof(value)), }; /// - public int CompareTo(ISemanticString? other) => WeakString.CompareTo(strB: other?.WeakString); + /// Ordering is ordinal; see for why. + public int CompareTo(ISemanticString? other) => string.CompareOrdinal(WeakString, other?.WeakString); /// public bool Contains(string value) => WeakString.Contains(value: value); diff --git a/Semantics.Test/Paths/PathSortingTests.cs b/Semantics.Test/Paths/PathSortingTests.cs index d1f4cf7c..db420c6d 100644 --- a/Semantics.Test/Paths/PathSortingTests.cs +++ b/Semantics.Test/Paths/PathSortingTests.cs @@ -108,4 +108,18 @@ public void DefaultComparer_ForPathInterface_UsesGenericComparison() Assert.IsTrue(comparerName.StartsWith("GenericComparer", StringComparison.Ordinal), $"Actual comparer: {comparerName}"); } + + [TestMethod] + public void DefaultComparer_ForPathInterface_OrdersOrdinally() + { + // IComparable.CompareTo must order the same way as the inherited CompareTo(object), + // which is ordinal. A linguistic comparison sorts by letter before case, so it would put + // "apple" first and make the order depend on the running culture. + AbsoluteDirectoryPath upper = MakeDirectory("Zebra"); + AbsoluteDirectoryPath lower = MakeDirectory("apple"); + + int expected = Math.Sign(string.CompareOrdinal(upper.WeakString, lower.WeakString)); + + Assert.AreEqual(expected, Math.Sign(Comparer.Default.Compare(upper, lower)), "Sorting through IComparable must follow ordinal order"); + } } diff --git a/Semantics.Test/SemanticStringTests.cs b/Semantics.Test/SemanticStringTests.cs index d015b039..4fec2eda 100644 --- a/Semantics.Test/SemanticStringTests.cs +++ b/Semantics.Test/SemanticStringTests.cs @@ -1247,6 +1247,84 @@ public void Sort_AsNonComparableInterface_OrdersByUnderlyingValue() Assert.AreSequenceEqual(expected, sorted); } + /// + /// Pairs a linguistic comparison orders differently from an ordinal one. The first three flip + /// sign, because collation sorts by letter before case while ordinal sorts by code unit, so every + /// uppercase letter precedes every lowercase one. The last two are the punctuation cases: Windows + /// NLS gives a hyphen minimal collation weight and reports them equal, which also contradicts the + /// ordinal Equals. ICU does not, which is why they are grouped separately below. + /// + private static readonly (string First, string Second)[] CaseOrderingPairs = + [ + ("Zebra", "apple"), + ("apple", "Banana"), + ("MySemanticString", "mysemanticstring"), + ]; + + private static readonly (string First, string Second)[] PunctuationPairs = + [ + ("Co-op", "Coop"), + ("e-mail", "email"), + ]; + + [TestMethod] + public void CompareTo_OrdersOrdinally() + { + foreach ((string first, string second) in CaseOrderingPairs.Concat(PunctuationPairs)) + { + MySemanticString left = SemanticString.Create(first); + MySemanticString right = SemanticString.Create(second); + int expected = Math.Sign(string.CompareOrdinal(first, second)); + + Assert.AreEqual(expected, Math.Sign(left.CompareTo(right)), $"CompareTo(ISemanticString) must order '{first}' and '{second}' ordinally"); + + // A string is not an ISemanticString, so this binds to the non-generic CompareTo(object). + Assert.AreEqual(expected, Math.Sign(left.CompareTo(second)), $"CompareTo(object) must order '{first}' and '{second}' ordinally"); + Assert.AreEqual(expected, Math.Sign(left.CompareTo((object)right)), $"CompareTo(object) must order '{first}' and '{second}' ordinally for a semantic string too"); + } + } + + [TestMethod] + public void ComparisonOperators_OrderOrdinally() + { + foreach ((string first, string second) in CaseOrderingPairs.Concat(PunctuationPairs)) + { + MySemanticString left = SemanticString.Create(first); + MySemanticString right = SemanticString.Create(second); + bool ordinallyLess = string.CompareOrdinal(first, second) < 0; + + Assert.AreEqual(ordinallyLess, left < right, $"'{first}' < '{second}' must follow ordinal order"); + Assert.AreEqual(ordinallyLess, left <= right, $"'{first}' <= '{second}' must follow ordinal order"); + Assert.AreEqual(!ordinallyLess, left > right, $"'{first}' > '{second}' must follow ordinal order"); + Assert.AreEqual(!ordinallyLess, left >= right, $"'{first}' >= '{second}' must follow ordinal order"); + } + } + + [TestMethod] + public void CompareTo_IsZeroExactlyWhenEqual() + { + // The IComparable/IEquatable consistency contract, which is what a SortedSet relies on: it + // treats any two values comparing 0 as duplicates and silently discards the second. + foreach ((string first, string second) in CaseOrderingPairs.Concat(PunctuationPairs)) + { + MySemanticString left = SemanticString.Create(first); + MySemanticString right = SemanticString.Create(second); + + // A second instance rather than `left` itself: comparing an instance with its own + // reference holds for any implementation, so it would not pin the other half of the + // contract — that two Equals-equal values also compare 0. + MySemanticString sameValueAsLeft = SemanticString.Create(first); + + Assert.IsFalse(left.Equals(right), $"'{first}' and '{second}' are distinct values"); + Assert.AreNotEqual(0, left.CompareTo(right), $"CompareTo must not report '{first}' and '{second}' as equal when Equals does not"); + Assert.IsTrue(left.Equals(sameValueAsLeft), $"Two instances of '{first}' are equal values"); + Assert.AreEqual(0, left.CompareTo(sameValueAsLeft), $"CompareTo must report two instances of '{first}' as equal when Equals does"); + + SortedSet set = [left, right]; + Assert.HasCount(2, set, $"'{first}' and '{second}' are distinct values and must both be retained"); + } + } + [TestMethod] public void DebuggerDisplay_ReturnsCorrectFormat() {