diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0443de5..340a65c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,9 @@ jobs: - name: Restore dependencies run: dotnet restore ControlPad/ControlPad.csproj + - name: Run unit tests + run: dotnet test ControlPad.Tests/ControlPad.Tests.csproj -c Release + - name: Publish (framework-dependent) run: dotnet publish ControlPad/ControlPad.csproj -c Release -r win-x64 --no-self-contained -o publish diff --git a/.gitignore b/.gitignore index b58663d..bd2a898 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ ControlPad/bin/ ControlPad/obj/ ControlPad/publish/ +ControlPad.Tests/bin/ +ControlPad.Tests/obj/ *.user *.suo @@ -13,4 +15,4 @@ ControlPad/publish/ # Installer output Installer/Output/ -Installer/redist/ \ No newline at end of file +Installer/redist/ diff --git a/ControlPad.Tests/ActionTypeTests.cs b/ControlPad.Tests/ActionTypeTests.cs new file mode 100644 index 0000000..a91fd9b --- /dev/null +++ b/ControlPad.Tests/ActionTypeTests.cs @@ -0,0 +1,30 @@ +using ControlPad; + +namespace ControlPad.Tests +{ + public class ActionTypeTests + { + [Fact] + public void Constructor_SetsTypeAndDescription() + { + var actionType = new ActionType(EActionType.OpenWebsite, "Open Website"); + + Assert.Equal(EActionType.OpenWebsite, actionType.Type); + Assert.Equal("Open Website", actionType.Description); + } + + [Fact] + public void Enum_DefinesExpectedActionTypes() + { + var values = Enum.GetValues(); + + Assert.Contains(EActionType.MuteProcess, values); + Assert.Contains(EActionType.MuteMainAudio, values); + Assert.Contains(EActionType.MuteMic, values); + Assert.Contains(EActionType.OpenProcess, values); + Assert.Contains(EActionType.OpenWebsite, values); + Assert.Contains(EActionType.KeyPress, values); + Assert.Equal(6, values.Length); + } + } +} diff --git a/ControlPad.Tests/AudioStreamTests.cs b/ControlPad.Tests/AudioStreamTests.cs new file mode 100644 index 0000000..edd1a5d --- /dev/null +++ b/ControlPad.Tests/AudioStreamTests.cs @@ -0,0 +1,39 @@ +using ControlPad; + +namespace ControlPad.Tests +{ + public class AudioStreamTests + { + [Fact] + public void Constructor_UsesMicNameAsDisplayName_WhenMicNameProvided() + { + var stream = new AudioStream("spotify", "USB Mic"); + + Assert.Equal("USB Mic", stream.DisplayName); + } + + [Fact] + public void Constructor_UsesProcessAsDisplayName_WhenOnlyProcessProvided() + { + var stream = new AudioStream("discord", null); + + Assert.Equal("discord", stream.DisplayName); + } + + [Fact] + public void Constructor_UsesMainAudio_WhenProcessAndMicAreNull() + { + var stream = new AudioStream(null, null); + + Assert.Equal("Main Audio", stream.DisplayName); + } + + [Fact] + public void Constructor_PrioritizesMicNameOverProcess_WhenBothProvided() + { + var stream = new AudioStream("game", "Headset Mic"); + + Assert.Equal("Headset Mic", stream.DisplayName); + } + } +} diff --git a/ControlPad.Tests/ButtonCategoryTests.cs b/ControlPad.Tests/ButtonCategoryTests.cs new file mode 100644 index 0000000..3cc0d28 --- /dev/null +++ b/ControlPad.Tests/ButtonCategoryTests.cs @@ -0,0 +1,40 @@ +using ControlPad; + +namespace ControlPad.Tests +{ + public class ButtonCategoryTests + { + [Fact] + public void Constructor_InitializesNameIdAndEmptyActions() + { + var category = new ButtonCategory("Actions", 2); + + Assert.Equal("Actions", category.Name); + Assert.Equal(2, category.Id); + Assert.NotNull(category.ButtonActions); + Assert.Empty(category.ButtonActions); + } + + [Fact] + public void ToString_ReturnsName() + { + var category = new ButtonCategory("Macros", 9); + + Assert.Equal("Macros", category.ToString()); + } + + [Fact] + public void ButtonActions_CanAddAndRemoveEntries() + { + var category = new ButtonCategory("Media", 1); + var actionType = new ActionType(EActionType.MuteMainAudio, "Mute"); + var action = new ButtonAction(actionType) { ActionProperty = null }; + + category.ButtonActions.Add(action); + Assert.Single(category.ButtonActions); + + category.ButtonActions.Remove(action); + Assert.Empty(category.ButtonActions); + } + } +} diff --git a/ControlPad.Tests/ControlPad.Tests.csproj b/ControlPad.Tests/ControlPad.Tests.csproj new file mode 100644 index 0000000..a4531a8 --- /dev/null +++ b/ControlPad.Tests/ControlPad.Tests.csproj @@ -0,0 +1,32 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ControlPad.Tests/IdAllocatorTests.cs b/ControlPad.Tests/IdAllocatorTests.cs new file mode 100644 index 0000000..6501c05 --- /dev/null +++ b/ControlPad.Tests/IdAllocatorTests.cs @@ -0,0 +1,47 @@ +using ControlPad.Utils; + +namespace ControlPad.Tests +{ + public class IdAllocatorTests + { + [Fact] + public void GetFreeId_ReturnsZero_WhenCollectionEmpty() + { + var result = IdAllocator.GetFreeId(Array.Empty(), x => x); + + Assert.Equal(0, result); + } + + [Fact] + public void GetFreeId_ReturnsOne_WhenZeroExists() + { + var result = IdAllocator.GetFreeId(new[] { 0 }, x => x); + + Assert.Equal(1, result); + } + + [Fact] + public void GetFreeId_FindsGapInSequence() + { + var result = IdAllocator.GetFreeId(new[] { 0, 2, 3 }, x => x); + + Assert.Equal(1, result); + } + + [Fact] + public void GetFreeId_IgnoresNegativeIds_AndReturnsZeroWhenMissing() + { + var result = IdAllocator.GetFreeId(new[] { -10, -1, 4, 7 }, x => x); + + Assert.Equal(0, result); + } + + [Fact] + public void GetFreeId_HandlesLargeNonSequentialNumbers() + { + var result = IdAllocator.GetFreeId(new[] { 0, 1, 2, 1000, 5000 }, x => x); + + Assert.Equal(3, result); + } + } +} diff --git a/ControlPad.Tests/PresetTests.cs b/ControlPad.Tests/PresetTests.cs new file mode 100644 index 0000000..9422b0e --- /dev/null +++ b/ControlPad.Tests/PresetTests.cs @@ -0,0 +1,56 @@ +using System.ComponentModel; +using ControlPad; + +namespace ControlPad.Tests +{ + public class PresetTests + { + [Fact] + public void Constructor_SetsInitialValues() + { + var preset = new Preset(2, "Gaming"); + + Assert.Equal(2, preset.Id); + Assert.Equal("Gaming", preset.Name); + } + + [Fact] + public void Id_SetToDifferentValue_RaisesPropertyChanged() + { + var preset = new Preset(1, "Default"); + PropertyChangedEventArgs? captured = null; + preset.PropertyChanged += (_, e) => captured = e; + + preset.Id = 3; + + Assert.NotNull(captured); + Assert.Equal(nameof(Preset.Id), captured!.PropertyName); + } + + [Fact] + public void Name_SetToDifferentValue_RaisesPropertyChanged() + { + var preset = new Preset(1, "Default"); + PropertyChangedEventArgs? captured = null; + preset.PropertyChanged += (_, e) => captured = e; + + preset.Name = "Streaming"; + + Assert.NotNull(captured); + Assert.Equal(nameof(Preset.Name), captured!.PropertyName); + } + + [Fact] + public void SettingSameValues_DoesNotRaisePropertyChanged() + { + var preset = new Preset(1, "Default"); + int events = 0; + preset.PropertyChanged += (_, _) => events++; + + preset.Id = 1; + preset.Name = "Default"; + + Assert.Equal(0, events); + } + } +} diff --git a/ControlPad.Tests/SliderCategoryTests.cs b/ControlPad.Tests/SliderCategoryTests.cs new file mode 100644 index 0000000..1cfc00d --- /dev/null +++ b/ControlPad.Tests/SliderCategoryTests.cs @@ -0,0 +1,58 @@ +using System.Collections.ObjectModel; +using ControlPad; + +namespace ControlPad.Tests +{ + public class SliderCategoryTests + { + [Fact] + public void Constructor_InitializesNameIdAndEmptyAudioStreams() + { + var category = new SliderCategory("Voice", 12); + + Assert.Equal("Voice", category.Name); + Assert.Equal(12, category.Id); + Assert.NotNull(category.AudioStreams); + Assert.Empty(category.AudioStreams); + } + + [Fact] + public void ToString_ReturnsName() + { + var category = new SliderCategory("Music", 4); + + Assert.Equal("Music", category.ToString()); + } + + [Fact] + public void ChangingIdNameAudioStreams_RaisesPropertyChangedForEach() + { + var category = new SliderCategory("Old", 1); + var changed = new List(); + category.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + category.Id = 5; + category.Name = "New"; + category.AudioStreams = new ObservableCollection { new AudioStream("spotify", null) }; + + Assert.Contains(nameof(SliderCategory.Id), changed); + Assert.Contains(nameof(SliderCategory.Name), changed); + Assert.Contains(nameof(SliderCategory.AudioStreams), changed); + } + + [Fact] + public void SettingSameValues_DoesNotRaisePropertyChanged() + { + var category = new SliderCategory("Same", 1); + var sameCollection = category.AudioStreams; + int events = 0; + category.PropertyChanged += (_, _) => events++; + + category.Id = 1; + category.Name = "Same"; + category.AudioStreams = sameCollection; + + Assert.Equal(0, events); + } + } +} diff --git a/ControlPad.Tests/SliderValueConverterTests.cs b/ControlPad.Tests/SliderValueConverterTests.cs new file mode 100644 index 0000000..5477f8e --- /dev/null +++ b/ControlPad.Tests/SliderValueConverterTests.cs @@ -0,0 +1,56 @@ +using ControlPad.Converters; + +namespace ControlPad.Tests +{ + public class SliderValueConverterTests + { + [Fact] + public void SliderToFloat_ReturnsZero_ForRawValueOne() + { + var result = SliderValueConverter.SliderToFloat(1, 1d); + + Assert.Equal(0f, result); + } + + [Fact] + public void SliderToFloat_ReturnsOne_ForRawValue1023_WithExponentOne() + { + var result = SliderValueConverter.SliderToFloat(1023, 1d); + + Assert.Equal(1f, result, 5); + } + + [Fact] + public void SliderToFloat_ClampsToZero_ForValuesBelowThreshold() + { + var result = SliderValueConverter.SliderToFloat(5, 1d); + + Assert.Equal(0f, result); + } + + [Fact] + public void SliderToFloat_AppliesExponentCurve() + { + var linear = SliderValueConverter.SliderToFloat(512, 1d); + var curved = SliderValueConverter.SliderToFloat(512, 2d); + + Assert.True(curved < linear); + } + + [Fact] + public void SliderToFloat_ClampsInputBelowRange() + { + var result = SliderValueConverter.SliderToFloat(-100, 1d); + + Assert.Equal(0f, result); + } + + [Fact] + public void SliderToFloat_ClampsInputAboveRange() + { + var result = SliderValueConverter.SliderToFloat(5000, 1d); + + Assert.Equal(1f, result, 5); + } + } +} diff --git a/ControlPad.sln b/ControlPad.sln index b51f148..85baabe 100644 --- a/ControlPad.sln +++ b/ControlPad.sln @@ -5,16 +5,42 @@ VisualStudioVersion = 17.14.36212.18 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlPad", "ControlPad\ControlPad.csproj", "{D35BB3DA-59CF-B3B9-1C64-51118D02B4EA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlPad.Tests", "ControlPad.Tests\ControlPad.Tests.csproj", "{9BC9BA13-FBC5-47F4-9CAD-8864C84C4AC7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {D35BB3DA-59CF-B3B9-1C64-51118D02B4EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D35BB3DA-59CF-B3B9-1C64-51118D02B4EA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D35BB3DA-59CF-B3B9-1C64-51118D02B4EA}.Debug|x64.ActiveCfg = Debug|Any CPU + {D35BB3DA-59CF-B3B9-1C64-51118D02B4EA}.Debug|x64.Build.0 = Debug|Any CPU + {D35BB3DA-59CF-B3B9-1C64-51118D02B4EA}.Debug|x86.ActiveCfg = Debug|Any CPU + {D35BB3DA-59CF-B3B9-1C64-51118D02B4EA}.Debug|x86.Build.0 = Debug|Any CPU {D35BB3DA-59CF-B3B9-1C64-51118D02B4EA}.Release|Any CPU.ActiveCfg = Release|Any CPU {D35BB3DA-59CF-B3B9-1C64-51118D02B4EA}.Release|Any CPU.Build.0 = Release|Any CPU + {D35BB3DA-59CF-B3B9-1C64-51118D02B4EA}.Release|x64.ActiveCfg = Release|Any CPU + {D35BB3DA-59CF-B3B9-1C64-51118D02B4EA}.Release|x64.Build.0 = Release|Any CPU + {D35BB3DA-59CF-B3B9-1C64-51118D02B4EA}.Release|x86.ActiveCfg = Release|Any CPU + {D35BB3DA-59CF-B3B9-1C64-51118D02B4EA}.Release|x86.Build.0 = Release|Any CPU + {9BC9BA13-FBC5-47F4-9CAD-8864C84C4AC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9BC9BA13-FBC5-47F4-9CAD-8864C84C4AC7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9BC9BA13-FBC5-47F4-9CAD-8864C84C4AC7}.Debug|x64.ActiveCfg = Debug|Any CPU + {9BC9BA13-FBC5-47F4-9CAD-8864C84C4AC7}.Debug|x64.Build.0 = Debug|Any CPU + {9BC9BA13-FBC5-47F4-9CAD-8864C84C4AC7}.Debug|x86.ActiveCfg = Debug|Any CPU + {9BC9BA13-FBC5-47F4-9CAD-8864C84C4AC7}.Debug|x86.Build.0 = Debug|Any CPU + {9BC9BA13-FBC5-47F4-9CAD-8864C84C4AC7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9BC9BA13-FBC5-47F4-9CAD-8864C84C4AC7}.Release|Any CPU.Build.0 = Release|Any CPU + {9BC9BA13-FBC5-47F4-9CAD-8864C84C4AC7}.Release|x64.ActiveCfg = Release|Any CPU + {9BC9BA13-FBC5-47F4-9CAD-8864C84C4AC7}.Release|x64.Build.0 = Release|Any CPU + {9BC9BA13-FBC5-47F4-9CAD-8864C84C4AC7}.Release|x86.ActiveCfg = Release|Any CPU + {9BC9BA13-FBC5-47F4-9CAD-8864C84C4AC7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ControlPad/AudioStream.cs b/ControlPad/AudioStream.cs index 7bc4eb6..2b84791 100644 --- a/ControlPad/AudioStream.cs +++ b/ControlPad/AudioStream.cs @@ -1,4 +1,3 @@ -using NAudio.CoreAudioApi; using System; using System.Collections.Generic; using System.Linq; diff --git a/ControlPad/Converters/SliderValueConverter.cs b/ControlPad/Converters/SliderValueConverter.cs new file mode 100644 index 0000000..9767cff --- /dev/null +++ b/ControlPad/Converters/SliderValueConverter.cs @@ -0,0 +1,16 @@ +namespace ControlPad.Converters +{ + public static class SliderValueConverter + { + public static float SliderToFloat(int value, double translationExponent) + { + value -= 1; + float normalized = System.Math.Clamp((float)value / 1022.0f, 0f, 1f); + + if (normalized < 0.005f) + return 0f; + + return (float)System.Math.Pow(normalized, translationExponent); + } + } +} diff --git a/ControlPad/DataHandler.cs b/ControlPad/DataHandler.cs index 136b82d..d49d60e 100644 --- a/ControlPad/DataHandler.cs +++ b/ControlPad/DataHandler.cs @@ -8,6 +8,7 @@ using System.Windows; using System.Xml.Linq; using Windows.Storage.BulkAccess; +using ControlPad.Utils; namespace ControlPad { @@ -90,11 +91,7 @@ public static void LoadCategoryControls(string path) public static int GetFreeId(this IEnumerable items, Func idSelector) // gets the lowest, not yet existing id { - var used = new HashSet(items.Select(idSelector)); - int candidate = 0; - while (used.Contains(candidate)) - candidate++; - return candidate; + return IdAllocator.GetFreeId(items, idSelector); } public static void SetSliderTextBlocks() diff --git a/ControlPad/EventHandler.cs b/ControlPad/EventHandler.cs index bea3292..f7a37ca 100644 --- a/ControlPad/EventHandler.cs +++ b/ControlPad/EventHandler.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using ControlPad.Converters; +using System.Diagnostics; namespace ControlPad { @@ -182,14 +183,7 @@ private void ButtonEvent(CustomButton button, int currentValue, int oldValue) private float SliderToFloat(int value, int mode = 0) { - value -= 1; - float normalized = Math.Clamp((float)value / 1022.0f, 0f, 1f); - - // Clamp to zero when slider is at or near the bottom to ensure complete silence - if (normalized < 0.005f) - return 0f; - - return (float)Math.Pow(normalized, Settings.TranslationExponent); + return SliderValueConverter.SliderToFloat(value, Settings.TranslationExponent); } } } diff --git a/ControlPad/Utils/IdAllocator.cs b/ControlPad/Utils/IdAllocator.cs new file mode 100644 index 0000000..3673fbc --- /dev/null +++ b/ControlPad/Utils/IdAllocator.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ControlPad.Utils +{ + public static class IdAllocator + { + public static int GetFreeId(IEnumerable items, Func idSelector) + { + var used = new HashSet(items.Select(idSelector)); + int candidate = 0; + while (used.Contains(candidate)) + candidate++; + return candidate; + } + } +}