From b64ebda341724881c1b40f04113ef90f998f8b41 Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Sun, 27 Sep 2026 01:27:53 +0000 Subject: [PATCH] Normalize the key in the Note(NoteName) constructor as the string one does The [JsonConstructor] Note(NoteName) overload canonicalized the key without trimming or uppercasing it first, so NoteName "control" stayed "control" rather than becoming CTRL, and "s" stayed lowercase. Chord.ToString() uppercases for display, so the resulting chord printed as "Ctrl+S" but was not equal to Chord.Parse("Ctrl+S"). Both constructors now go through one NormalizeKey helper. Fixes #121 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qSqApi9HSiBjyHaJQYPTP --- .../ModifierAliasNormalizationTests.cs | 19 +++++++++++++++++++ Keybinding/Models/MusicalTypes.cs | 12 ++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Keybinding.Test/ModifierAliasNormalizationTests.cs b/Keybinding.Test/ModifierAliasNormalizationTests.cs index 832f06e..bdd46f0 100644 --- a/Keybinding.Test/ModifierAliasNormalizationTests.cs +++ b/Keybinding.Test/ModifierAliasNormalizationTests.cs @@ -55,6 +55,8 @@ public void EveryConstructionPath_NormalizesModifierAliases(string alias, string Chord.Parse($"{alias}+S"), new Chord([new Note(alias), new Note("S")]), new Chord([new Note(NoteName.Create(alias.ToUpperInvariant())), new Note("S")]), + new Chord([new Note(NoteName.Create(alias)), new Note(NoteName.Create("s"))]), + new Chord([new Note(NoteName.Create(alias.ToLowerInvariant())), new Note("S")]), ]; foreach (Chord chord in built) @@ -64,6 +66,23 @@ public void EveryConstructionPath_NormalizesModifierAliases(string alias, string } } + [TestMethod] + [DataRow("control")] + [DataRow("Control")] + [DataRow("ctrl")] + [DataRow("s")] + [DataRow("f5")] + [DataRow(" cmd ")] + public void NoteNameConstructor_MatchesStringConstructor(string key) + { + Note fromString = new(key); + Note fromNoteName = new(NoteName.Create(key)); + + Assert.AreEqual(fromString, fromNoteName); + Assert.AreEqual(fromString.GetHashCode(), fromNoteName.GetHashCode()); + Assert.AreEqual(fromString.Key.ToString(), fromNoteName.Key.ToString()); + } + [TestMethod] public void ParseChord_AliasAndCanonicalName_CollapseToOneNote() { diff --git a/Keybinding/Models/MusicalTypes.cs b/Keybinding/Models/MusicalTypes.cs index c7af056..83dbdce 100644 --- a/Keybinding/Models/MusicalTypes.cs +++ b/Keybinding/Models/MusicalTypes.cs @@ -18,7 +18,7 @@ public sealed class Note : IEquatable public Note(NoteName key) { Ensure.NotNull(key); - string canonical = CanonicalizeKey(key.ToString()); + string canonical = NormalizeKey(key.ToString()); Key = canonical == key.ToString() ? key : NoteName.Create(canonical); } @@ -34,9 +34,17 @@ public Note(string key) throw new ArgumentException("Key cannot be null or whitespace", nameof(key)); } - Key = NoteName.Create(CanonicalizeKey(key.Trim().ToUpperInvariant())); + Key = NoteName.Create(NormalizeKey(key)); } + /// + /// Trims, uppercases and canonicalizes a key name. Both constructors go through this, so a note + /// compares and hashes the same however it was built. + /// + /// A key name in any case + /// The canonical key name + private static string NormalizeKey(string key) => CanonicalizeKey(key.Trim().ToUpperInvariant()); + /// /// Maps a modifier alias to its canonical key name, so that every way of building a note /// (parsing, constructing directly, or loading a stored profile) compares and hashes the same.