Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions RadialActions.Tests/ActionEditorViewModelTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
120 changes: 120 additions & 0 deletions RadialActions.Tests/ActionValidationTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
104 changes: 104 additions & 0 deletions RadialActions/Data/ActionValidation.cs
Original file line number Diff line number Diff line change
@@ -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<ActionValidationIssue> Validate(PieAction action)
=> Validate(action, Directory.Exists, File.Exists);

internal static IReadOnlyList<ActionValidationIssue> Validate(
PieAction action,
Func<string, bool> directoryExists,
Func<string, bool> fileExists)
{
ArgumentNullException.ThrowIfNull(directoryExists);
ArgumentNullException.ThrowIfNull(fileExists);

if (action == null)
return [];

List<ActionValidationIssue> 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<ActionValidationIssue> 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<ActionValidationIssue> issues,
Func<string, bool> directoryExists,
Func<string, bool> 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);
}
}
49 changes: 49 additions & 0 deletions RadialActions/Settings/ActionEditorView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,46 @@
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/RadialActions;component/Settings/SettingsViewResources.xaml" />
</ResourceDictionary.MergedDictionaries>
<DataTemplate DataType="{x:Type local:ActionValidationIssue}">
<Border Margin="0,0,0,6"
Padding="10,8"
CornerRadius="8">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#FFFCEFC7" />
<Setter Property="BorderBrush" Value="#FFC28D00" />
<Setter Property="BorderThickness" Value="1" />
<Style.Triggers>
<DataTrigger Binding="{Binding Severity}"
Value="{x:Static local:ActionValidationSeverity.Error}">
<Setter Property="Background" Value="#FFFDE7E9" />
<Setter Property="BorderBrush" Value="#FFD13438" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<DockPanel LastChildFill="True">
<TextBlock Margin="0,0,8,0"
FontWeight="SemiBold">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="#FF7A5A00" />
<Setter Property="Text" Value="Warning" />
<Style.Triggers>
<DataTrigger Binding="{Binding Severity}"
Value="{x:Static local:ActionValidationSeverity.Error}">
<Setter Property="Foreground" Value="#FFC42B1C" />
<Setter Property="Text" Value="Error" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding Message}"
TextWrapping="Wrap" />
</DockPanel>
</Border>
</DataTemplate>
</ResourceDictionary>
</UserControl.Resources>
<Grid>
Expand All @@ -25,6 +65,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>

<TextBlock Grid.Row="0"
Expand Down Expand Up @@ -152,5 +193,13 @@
Padding="8,2" />
</Grid>
</Grid>

<ItemsControl Grid.Row="10"
ItemsSource="{Binding ValidationIssues}"
Visibility="{Binding HasValidationIssues, Converter={local:BooleanToVisibilityConverter}}">
<ItemsControl.Margin>
<Thickness>0,4,0,0</Thickness>
</ItemsControl.Margin>
</ItemsControl>
</Grid>
</UserControl>
13 changes: 13 additions & 0 deletions RadialActions/Settings/ActionEditorViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ public partial class ActionEditorViewModel : ObservableObject
[NotifyPropertyChangedFor(nameof(SelectedKeyActionId))]
private PieAction _selectedAction;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasValidationIssues))]
private IReadOnlyList<ActionValidationIssue> _validationIssues = [];

public ActionEditorViewModel(ActionDefaultsService actionDefaultsService, IEnumerable<PieAction> actions)
{
_actionDefaultsService = actionDefaultsService;
Expand All @@ -30,6 +34,7 @@ public ActionEditorViewModel(ActionDefaultsService actionDefaultsService, IEnume
public IReadOnlyList<KeyActionDefinition> KeyActionOptions { get; } =
[.. PieAction.KeyActions, CustomKeyActionOption];
public bool HasSelectedAction => SelectedAction != null;
public bool HasValidationIssues => ValidationIssues.Count > 0;

public ActionType SelectedActionType
{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -192,5 +198,12 @@ private void SelectedActionPropertyChanged(object sender, PropertyChangedEventAr
{
_actionDefaultsService.ApplyKeyDefaults(SelectedAction, definition);
}

RefreshValidation();
}

private void RefreshValidation()
{
ValidationIssues = ActionValidator.Validate(SelectedAction);
}
}
Loading