From 48fb44e929bac6a4b0110b9c2d6f64fcccfa7dac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 16:09:14 +0000 Subject: [PATCH 1/2] Add action validation model and editor feedback Agent-Logs-Url: https://github.com/danielchalmers/RadialActions/sessions/db749a63-4114-401e-b3d2-ec6f9d765816 Co-authored-by: danielchalmers <7112040+danielchalmers@users.noreply.github.com> --- RadialActions.Tests/ActionValidationTests.cs | 120 ++++++++++++++++++ RadialActions/Data/ActionValidation.cs | 104 +++++++++++++++ RadialActions/Settings/ActionEditorView.xaml | 49 +++++++ .../Settings/ActionEditorViewModel.cs | 13 ++ 4 files changed, 286 insertions(+) create mode 100644 RadialActions.Tests/ActionValidationTests.cs create mode 100644 RadialActions/Data/ActionValidation.cs diff --git a/RadialActions.Tests/ActionValidationTests.cs b/RadialActions.Tests/ActionValidationTests.cs new file mode 100644 index 0000000..bf98e32 --- /dev/null +++ b/RadialActions.Tests/ActionValidationTests.cs @@ -0,0 +1,120 @@ +namespace RadialActions.Tests; + +public sealed class ActionValidationTests +{ + [Fact] + public void Validate_NoneAction_ReturnsTypeError() + { + var action = new PieAction("Blank") { Type = ActionType.None }; + + var issues = ActionValidator.Validate(action); + + var issue = Assert.Single(issues); + Assert.Equal(ActionValidationSeverity.Error, issue.Severity); + Assert.Equal("Choose an action type.", issue.Message); + } + + [Fact] + public void Validate_UnsupportedActionType_ReturnsUnsupportedError() + { + var action = new PieAction("Broken") { Type = (ActionType)999 }; + + var issues = ActionValidator.Validate(action, _ => false, _ => false); + + var issue = Assert.Single(issues); + Assert.Equal(ActionValidationSeverity.Error, issue.Severity); + Assert.Equal("This action type is not supported.", issue.Message); + } + + [Fact] + public void Validate_KeyActionWithoutShortcut_ReturnsShortcutError() + { + var action = new PieAction("Shortcut") { Type = ActionType.Key, Parameter = "" }; + + var issues = ActionValidator.Validate(action); + + var issue = Assert.Single(issues); + Assert.Equal(ActionValidationSeverity.Error, issue.Severity); + Assert.Equal("Select a key action or enter a custom shortcut.", issue.Message); + } + + [Fact] + public void Validate_KeyActionWithInvalidCustomShortcut_ReturnsShortcutError() + { + var action = new PieAction("Shortcut") { Type = ActionType.Key, Parameter = "DefinitelyNotAHotkey" }; + + var issues = ActionValidator.Validate(action); + + var issue = Assert.Single(issues); + Assert.Equal(ActionValidationSeverity.Error, issue.Severity); + Assert.Equal("Custom shortcut is invalid.", issue.Message); + } + + [Fact] + public void Validate_KeyActionWithKnownShortcut_ReturnsNoIssues() + { + var action = PieAction.CreateKeyAction("Mute"); + + var issues = ActionValidator.Validate(action); + + Assert.Empty(issues); + } + + [Fact] + public void Validate_ShellActionWithoutTarget_ReturnsTargetError() + { + var action = PieAction.CreateShellAction("Docs", string.Empty); + + var issues = ActionValidator.Validate(action, _ => false, _ => false); + + var issue = Assert.Single(issues); + Assert.Equal(ActionValidationSeverity.Error, issue.Severity); + Assert.Equal("Choose a target to launch.", issue.Message); + } + + [Fact] + public void Validate_ShellActionWithMissingWorkingDirectory_ReturnsError() + { + var action = PieAction.CreateShellAction("Docs", "explorer.exe", workingDirectory: @"C:\missing"); + + var issues = ActionValidator.Validate( + action, + directory => directory == @"C:\exists", + _ => false); + + Assert.Collection( + issues, + issue => + { + Assert.Equal(ActionValidationSeverity.Warning, issue.Severity); + Assert.Equal("Target does not exist right now. The shell may still resolve it when the action runs.", issue.Message); + }, + issue => + { + Assert.Equal(ActionValidationSeverity.Error, issue.Severity); + Assert.Equal("Working directory does not exist.", issue.Message); + }); + } + + [Fact] + public void Validate_ShellActionWithMissingTarget_ReturnsWarning() + { + var action = PieAction.CreateShellAction("Docs", "explorer.exe"); + + var issues = ActionValidator.Validate(action, _ => false, _ => false); + + var issue = Assert.Single(issues); + Assert.Equal(ActionValidationSeverity.Warning, issue.Severity); + Assert.Equal("Target does not exist right now. The shell may still resolve it when the action runs.", issue.Message); + } + + [Fact] + public void Validate_ShellActionWithAbsoluteUrl_ReturnsNoTargetWarning() + { + var action = PieAction.CreateShellAction("Docs", "https://example.com"); + + var issues = ActionValidator.Validate(action, _ => false, _ => false); + + Assert.Empty(issues); + } +} diff --git a/RadialActions/Data/ActionValidation.cs b/RadialActions/Data/ActionValidation.cs new file mode 100644 index 0000000..a4b089f --- /dev/null +++ b/RadialActions/Data/ActionValidation.cs @@ -0,0 +1,104 @@ +using System.IO; + +namespace RadialActions; + +public enum ActionValidationSeverity +{ + Warning = 0, + Error = 1, +} + +public sealed record ActionValidationIssue(ActionValidationSeverity Severity, string Message); + +public static class ActionValidator +{ + public static IReadOnlyList Validate(PieAction action) + => Validate(action, Directory.Exists, File.Exists); + + internal static IReadOnlyList Validate( + PieAction action, + Func directoryExists, + Func fileExists) + { + ArgumentNullException.ThrowIfNull(directoryExists); + ArgumentNullException.ThrowIfNull(fileExists); + + if (action == null) + return []; + + List issues = []; + + if (!Enum.IsDefined(action.Type)) + { + issues.Add(new(ActionValidationSeverity.Error, "This action type is not supported.")); + return issues; + } + + switch (action.Type) + { + case ActionType.None: + issues.Add(new(ActionValidationSeverity.Error, "Choose an action type.")); + break; + + case ActionType.Key: + ValidateKeyAction(action, issues); + break; + + case ActionType.Shell: + ValidateShellAction(action, issues, directoryExists, fileExists); + break; + } + + return issues; + } + + private static void ValidateKeyAction(PieAction action, List issues) + { + if (string.IsNullOrWhiteSpace(action.Parameter)) + { + issues.Add(new(ActionValidationSeverity.Error, "Select a key action or enter a custom shortcut.")); + return; + } + + if (!PieAction.TryGetKeyAction(action.Parameter, out _) && + !HotkeyUtil.TryParse(action.Parameter, out _, out _)) + { + issues.Add(new(ActionValidationSeverity.Error, "Custom shortcut is invalid.")); + } + } + + private static void ValidateShellAction( + PieAction action, + List issues, + Func directoryExists, + Func fileExists) + { + var target = action.Parameter?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(target)) + { + issues.Add(new(ActionValidationSeverity.Error, "Choose a target to launch.")); + } + else if (!LooksLikeAbsoluteUri(target) && + !fileExists(target) && + !directoryExists(target)) + { + issues.Add(new( + ActionValidationSeverity.Warning, + "Target does not exist right now. The shell may still resolve it when the action runs.")); + } + + var workingDirectory = action.WorkingDirectory?.Trim() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(workingDirectory) && + !directoryExists(workingDirectory)) + { + issues.Add(new(ActionValidationSeverity.Error, "Working directory does not exist.")); + } + } + + private static bool LooksLikeAbsoluteUri(string target) + { + return Uri.TryCreate(target, UriKind.Absolute, out var uri) && + !uri.IsFile && + !string.IsNullOrWhiteSpace(uri.Scheme); + } +} diff --git a/RadialActions/Settings/ActionEditorView.xaml b/RadialActions/Settings/ActionEditorView.xaml index 98bb488..ef511a1 100644 --- a/RadialActions/Settings/ActionEditorView.xaml +++ b/RadialActions/Settings/ActionEditorView.xaml @@ -11,6 +11,46 @@ + + + + + + + + + + + + + + + @@ -25,6 +65,7 @@ + + + + + 0,4,0,0 + + diff --git a/RadialActions/Settings/ActionEditorViewModel.cs b/RadialActions/Settings/ActionEditorViewModel.cs index cdf12f4..ef29b04 100644 --- a/RadialActions/Settings/ActionEditorViewModel.cs +++ b/RadialActions/Settings/ActionEditorViewModel.cs @@ -20,6 +20,10 @@ public partial class ActionEditorViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(SelectedKeyActionId))] private PieAction _selectedAction; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasValidationIssues))] + private IReadOnlyList _validationIssues = []; + public ActionEditorViewModel(ActionDefaultsService actionDefaultsService, IEnumerable actions) { _actionDefaultsService = actionDefaultsService; @@ -30,6 +34,7 @@ public ActionEditorViewModel(ActionDefaultsService actionDefaultsService, IEnume public IReadOnlyList KeyActionOptions { get; } = [.. PieAction.KeyActions, CustomKeyActionOption]; public bool HasSelectedAction => SelectedAction != null; + public bool HasValidationIssues => ValidationIssues.Count > 0; public ActionType SelectedActionType { @@ -165,6 +170,7 @@ partial void OnSelectedActionChanged(PieAction oldValue, PieAction newValue) OnPropertyChanged(nameof(SelectedActionType)); OnPropertyChanged(nameof(SelectedKeyActionId)); + RefreshValidation(); } private void SelectedActionPropertyChanged(object sender, PropertyChangedEventArgs e) @@ -192,5 +198,12 @@ private void SelectedActionPropertyChanged(object sender, PropertyChangedEventAr { _actionDefaultsService.ApplyKeyDefaults(SelectedAction, definition); } + + RefreshValidation(); + } + + private void RefreshValidation() + { + ValidationIssues = ActionValidator.Validate(SelectedAction); } } From 0fe4e0873d085ca4def3afbe5c33b710a5e5d45b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 16:09:50 +0000 Subject: [PATCH 2/2] Add action editor validation tests Agent-Logs-Url: https://github.com/danielchalmers/RadialActions/sessions/db749a63-4114-401e-b3d2-ec6f9d765816 Co-authored-by: danielchalmers <7112040+danielchalmers@users.noreply.github.com> --- .../ActionEditorViewModelTests.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 RadialActions.Tests/ActionEditorViewModelTests.cs diff --git a/RadialActions.Tests/ActionEditorViewModelTests.cs b/RadialActions.Tests/ActionEditorViewModelTests.cs new file mode 100644 index 0000000..a89114a --- /dev/null +++ b/RadialActions.Tests/ActionEditorViewModelTests.cs @@ -0,0 +1,52 @@ +namespace RadialActions.Tests; + +public sealed class ActionEditorViewModelTests +{ + [Fact] + public void SelectedAction_WithInvalidCustomShortcut_ShowsValidationUntilShortcutIsFixed() + { + var action = new PieAction("Shortcut") + { + Type = ActionType.Key, + Parameter = "DefinitelyNotAHotkey" + }; + + var viewModel = new ActionEditorViewModel(new ActionDefaultsService(), [action]) + { + SelectedAction = action + }; + + var initialIssue = Assert.Single(viewModel.ValidationIssues); + Assert.Equal(ActionValidationSeverity.Error, initialIssue.Severity); + Assert.Equal("Custom shortcut is invalid.", initialIssue.Message); + + action.Parameter = "Ctrl+Shift+R"; + + Assert.Empty(viewModel.ValidationIssues); + } + + [Fact] + public void SelectedActionType_UpdatesValidationWhenChoosingSupportedType() + { + var action = new PieAction("Blank") + { + Type = ActionType.None, + Parameter = string.Empty + }; + + var viewModel = new ActionEditorViewModel(new ActionDefaultsService(), [action]) + { + SelectedAction = action + }; + + var initialIssue = Assert.Single(viewModel.ValidationIssues); + Assert.Equal(ActionValidationSeverity.Error, initialIssue.Severity); + Assert.Equal("Choose an action type.", initialIssue.Message); + + viewModel.SelectedActionType = ActionType.Key; + + Assert.Equal(ActionType.Key, action.Type); + Assert.False(viewModel.HasValidationIssues); + Assert.Empty(viewModel.ValidationIssues); + } +}