From 5b5242a9273d0ec6b23b6240460efc530672b146 Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Sun, 27 Sep 2026 03:26:10 +0000 Subject: [PATCH] Return snapshots from GetAllChords and BoundCommands [patch] Profile.GetAllChords() wrapped the live chord dictionary and BoundCommands exposed its key collection, so a returned value changed after later binds and binding while iterating threw "Collection was modified". Both now copy, matching the other collection getters in the API. Fixes #127 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G89GFC9HBcemu37GarNas1 --- Keybinding.Test/GetAllChordsSnapshotTests.cs | 71 ++++++++++++++++++++ Keybinding/Models/Profile.cs | 4 +- 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 Keybinding.Test/GetAllChordsSnapshotTests.cs diff --git a/Keybinding.Test/GetAllChordsSnapshotTests.cs b/Keybinding.Test/GetAllChordsSnapshotTests.cs new file mode 100644 index 0000000..5a1e0dd --- /dev/null +++ b/Keybinding.Test/GetAllChordsSnapshotTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Keybinding.Test; + +using ktsu.Keybinding.Core.Models; +using ktsu.Keybinding.Core.Services; + +[TestClass] +public class GetAllChordsSnapshotTests +{ + private KeybindingService _service = null!; + private ProfileManager _profiles = null!; + + [TestInitialize] + public void Setup() + { + CommandRegistry registry = new(); + _profiles = new ProfileManager(); + _profiles.CreateProfile("p", "Profile"); + _profiles.SetActiveProfile("p"); + _service = new KeybindingService(registry, _profiles); + + registry.RegisterCommand(new Command("a", "A")); + registry.RegisterCommand(new Command("b", "B")); + } + + [TestMethod] + public void GetAllChords_LaterBindAndUnbind_DoNotChangeTheReturnedDictionary() + { + _service.BindChord("a", Chord.Parse("Ctrl+A")); + + IReadOnlyDictionary active = _service.GetAllChords(); + IReadOnlyDictionary byId = _service.GetAllChords("p"); + + _service.BindChord("b", Chord.Parse("Ctrl+B")); + _service.UnbindChord("a"); + + Assert.HasCount(1, active, "The active-profile snapshot should not see later bindings."); + Assert.IsTrue(active.ContainsKey("a"), "The active-profile snapshot should not lose a later-unbound command."); + Assert.HasCount(1, byId); + Assert.IsTrue(byId.ContainsKey("a")); + } + + [TestMethod] + public void GetAllChords_BindingWhileEnumerating_DoesNotThrow() + { + _service.BindChord("a", Chord.Parse("Ctrl+A")); + + foreach (KeyValuePair binding in _service.GetAllChords()) + { + if (!_service.HasChordBinding("b")) + { + _service.BindChord("b", binding.Value); + } + } + + Assert.IsTrue(_service.HasChordBinding("b")); + } + + [TestMethod] + public void BoundCommands_LaterBind_DoesNotChangeTheReturnedCollection() + { + _service.BindChord("a", Chord.Parse("Ctrl+A")); + Profile profile = _profiles.GetProfile("p")!; + + IReadOnlyCollection bound = profile.BoundCommands; + _service.BindChord("b", Chord.Parse("Ctrl+B")); + + Assert.HasCount(1, bound); + } +} diff --git a/Keybinding/Models/Profile.cs b/Keybinding/Models/Profile.cs index 9d22037..5891d3f 100644 --- a/Keybinding/Models/Profile.cs +++ b/Keybinding/Models/Profile.cs @@ -110,7 +110,7 @@ public void SetChord(string commandId, Chord chord) /// Gets all chord bindings for this profile /// /// Dictionary of command ID to chord mappings - public IReadOnlyDictionary GetAllChords() => Chords.AsReadOnly(); + public IReadOnlyDictionary GetAllChords() => new Dictionary(Chords).AsReadOnly(); /// /// Checks if a command has a chord binding in this profile @@ -142,7 +142,7 @@ public bool RemoveChord(string commandId) /// Gets all command IDs that have chord bindings in this profile /// /// Collection of command IDs - public IReadOnlyCollection BoundCommands => Chords.Keys; + public IReadOnlyCollection BoundCommands => [.. Chords.Keys]; /// /// Clears all chord bindings from this profile