diff --git a/Keybinding.Test/CommandJsonValidationTests.cs b/Keybinding.Test/CommandJsonValidationTests.cs new file mode 100644 index 0000000..db6a058 --- /dev/null +++ b/Keybinding.Test/CommandJsonValidationTests.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Keybinding.Test; + +using System.Text.Json; +using ktsu.Keybinding.Core.Models; + +[TestClass] +public class CommandJsonValidationTests +{ + [TestMethod] + [DataRow("""{"Name":"Save"}""")] + [DataRow("""{"Id":null,"Name":"Save"}""")] + [DataRow("""{"Id":" ","Name":"Save"}""")] + [DataRow("""{"Id":"file.save"}""")] + [DataRow("""{"Id":"file.save","Name":null}""")] + [DataRow("""{"Id":"file.save","Name":""}""")] + public void MissingOrBlankIdOrName_ThrowsJsonException(string json) => + Assert.ThrowsExactly(() => JsonSerializer.Deserialize(json)); + + [TestMethod] + public void ValidJson_StillNormalizesLikeThePublicConstructor() + { + Command? command = JsonSerializer.Deserialize("""{"Id":" file.save ","Name":" Save ","Description":" ","Category":" File "}"""); + + Assert.IsNotNull(command); + Assert.AreEqual("file.save", command.Id.ToString()); + Assert.AreEqual("Save", command.Name.ToString()); + Assert.IsNull(command.Description); + Assert.AreEqual("File", command.Category?.ToString()); + } + + [TestMethod] + public void PublicConstructor_StillThrowsArgumentException() => + Assert.ThrowsExactly(() => new Command(CommandId.Create(" "), CommandName.Create("Save"))); +} diff --git a/Keybinding/Models/Command.cs b/Keybinding/Models/Command.cs index 8a94203..5beba36 100644 --- a/Keybinding/Models/Command.cs +++ b/Keybinding/Models/Command.cs @@ -17,7 +17,6 @@ public sealed class Command : IEquatable /// Optional description of what the command does /// Optional category for grouping commands /// Thrown when id or name is null or whitespace - [JsonConstructor] public Command(CommandId id, CommandName name, CommandDescription? description = null, CommandCategory? category = null) { Ensure.NotNull(id); @@ -69,6 +68,22 @@ public Command(string id, string name, string? description = null, string? categ Category = TrimToNull(category) is string trimmedCategory ? CommandCategory.Create(trimmedCategory) : null; } + // The serializer binds constructor parameters by name, not position, so this takes name before id only to + // differ in signature from the public constructor. It reports a missing or blank id or name as malformed JSON, + // as Chord, Phrase and Profile do, rather than with the public constructor's ArgumentException. + [JsonConstructor] + [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "Called by System.Text.Json through [JsonConstructor].")] + private Command(CommandName? name, CommandId? id, CommandDescription? description, CommandCategory? category) + : this(RequireJsonText(id, nameof(Id)), RequireJsonText(name, nameof(Name)), description, category) + { + } + + private static T RequireJsonText(T? value, string propertyName) where T : class + { + JsonArguments.RequireText(value?.ToString(), nameof(Command), propertyName); + return value!; + } + private static string? TrimToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();