diff --git a/Keybinding.Test/ChordCultureInvarianceTests.cs b/Keybinding.Test/ChordCultureInvarianceTests.cs new file mode 100644 index 0000000..1473e2c --- /dev/null +++ b/Keybinding.Test/ChordCultureInvarianceTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Keybinding.Test; + +using System.Globalization; +using ktsu.Keybinding.Core.Models; + +[TestClass] +[DoNotParallelize] +public class ChordCultureInvarianceTests +{ + // et-EE sorts Z before T, and lt-LT sorts Y before J, so a culture-sensitive sort orders these pairs + // differently from en-US. + [TestMethod] + [DataRow("Ctrl+T+Z", "et-EE")] + [DataRow("Ctrl+J+Y", "lt-LT")] + public void HashCodeAndSetLookupDoNotDependOnTheThreadCulture(string text, string otherCulture) + { + CultureInfo original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("en-US"); + Chord chord = Chord.Parse(text); + HashSet set = [chord]; + Dictionary map = new() { [chord] = "handler" }; + int hash = chord.GetHashCode(); + + CultureInfo.CurrentCulture = new CultureInfo(otherCulture); + Chord reparsed = Chord.Parse(text); + + Assert.AreEqual(hash, chord.GetHashCode()); + Assert.AreEqual(hash, reparsed.GetHashCode()); + Assert.IsTrue(set.Contains(chord)); + Assert.IsTrue(set.Contains(reparsed)); + Assert.IsTrue(map.TryGetValue(reparsed, out string? handler)); + Assert.AreEqual("handler", handler); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [TestMethod] + [DataRow("Ctrl+T+Z", "et-EE")] + [DataRow("Ctrl+J+Y", "lt-LT")] + public void StoredNoteOrderDoesNotDependOnTheThreadCulture(string text, string otherCulture) + { + CultureInfo original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("en-US"); + Chord english = Chord.Parse(text); + + CultureInfo.CurrentCulture = new CultureInfo(otherCulture); + Chord other = Chord.Parse(text); + + CollectionAssert.AreEqual(english.Notes.ToList(), other.Notes.ToList()); + Assert.AreEqual(english.ToString(), other.ToString()); + Assert.AreEqual(text, other.ToString()); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } +} diff --git a/Keybinding.Test/Keybinding.Test.csproj b/Keybinding.Test/Keybinding.Test.csproj index 439c95d..7a6b91f 100644 --- a/Keybinding.Test/Keybinding.Test.csproj +++ b/Keybinding.Test/Keybinding.Test.csproj @@ -6,6 +6,8 @@ true net10.0 + + false diff --git a/Keybinding.Test/ModifierAliasNormalizationTests.cs b/Keybinding.Test/ModifierAliasNormalizationTests.cs index 449fd09..4c01dbc 100644 --- a/Keybinding.Test/ModifierAliasNormalizationTests.cs +++ b/Keybinding.Test/ModifierAliasNormalizationTests.cs @@ -43,6 +43,7 @@ private static KeybindingService CreateService() [DataRow("Windows", "Meta")] [DataRow("Cmd", "Meta")] [DataRow("Command", "Meta")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "The test feeds a lowercase alias on purpose.")] public void EveryConstructionPath_NormalizesModifierAliases(string alias, string canonical) { ArgumentNullException.ThrowIfNull(alias); diff --git a/Keybinding/Models/MusicalTypes.cs b/Keybinding/Models/MusicalTypes.cs index acbe933..48a027d 100644 --- a/Keybinding/Models/MusicalTypes.cs +++ b/Keybinding/Models/MusicalTypes.cs @@ -194,7 +194,9 @@ public Chord(IEnumerable notes) throw new ArgumentException("Chord cannot contain a null note", nameof(notes)); } - _notes = [.. given.Distinct().OrderBy(n => n.Key.ToString())]; + // Ordinal, so the stored order (which ToString, the saved file and GetHashCode rely on) is the same + // whatever the culture of the thread that built the chord. + _notes = [.. given.Distinct().OrderBy(n => n.Key.ToString(), StringComparer.Ordinal)]; if (_notes.Count == 0) { @@ -320,8 +322,9 @@ other is not null && /// public override int GetHashCode() { + // _notes is already in ordinal order, so equal chords hash their notes in the same order on any thread. HashCode hashCode = new(); - foreach (Note note in _notes.OrderBy(n => n.Key.ToString())) + foreach (Note note in _notes) { hashCode.Add(note); }