Skip to content
Merged
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
80 changes: 80 additions & 0 deletions Keybinding.Test/ModelJsonSerializationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Keybinding.Test;

using System.Text.Json;
using ktsu.Keybinding.Core.Models;

[TestClass]
public class ModelJsonSerializationTests
{
private static T? RoundTrip<T>(T value) => JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(value));

[TestMethod]
public void Chord_RoundTrip_ReturnsEqualChord()
{
Chord original = Chord.Parse("Ctrl+Shift+S");
Assert.AreEqual(original, RoundTrip(original));
}

[TestMethod]
public void Phrase_RoundTrip_ReturnsEqualPhrase()
{
Phrase original = Phrase.Parse("Ctrl+K, Ctrl+C");
Assert.AreEqual(original, RoundTrip(original));
}

[TestMethod]
public void Command_SerializesSemanticStringsAsStrings()
{
Command command = new("file.save", "Save", "Saves the file", "File");
using JsonDocument document = JsonDocument.Parse(JsonSerializer.Serialize(command));
JsonElement root = document.RootElement;

Assert.AreEqual("file.save", root.GetProperty(nameof(Command.Id)).GetString());
Assert.AreEqual("Save", root.GetProperty(nameof(Command.Name)).GetString());
Assert.AreEqual("Saves the file", root.GetProperty(nameof(Command.Description)).GetString());
Assert.AreEqual("File", root.GetProperty(nameof(Command.Category)).GetString());
}

[TestMethod]
public void Command_RoundTrip_PreservesEveryField()
{
Command original = new("file.save", "Save", "Saves the file", "File");
Command? roundTripped = RoundTrip(original);

Assert.IsNotNull(roundTripped);
Assert.AreEqual(original, roundTripped);
Assert.AreEqual(original.Name, roundTripped.Name);
Assert.AreEqual(original.Description, roundTripped.Description);
Assert.AreEqual(original.Category, roundTripped.Category);
}

[TestMethod]
public void Command_RoundTrip_KeepsMissingDescriptionAndCategoryNull()
{
Command? roundTripped = RoundTrip(new Command("file.save", "Save"));

Assert.IsNotNull(roundTripped);
Assert.IsNull(roundTripped.Description);
Assert.IsNull(roundTripped.Category);
}

[TestMethod]
public void Profile_RoundTrip_KeepsBoundChords()
{
Profile original = new("default", "Default", "The default profile");
original.SetChord("file.save", Chord.Parse("Ctrl+S"));
original.SetChord("edit.copy", Chord.Parse("Ctrl+C"));

Profile? roundTripped = RoundTrip(original);

Assert.IsNotNull(roundTripped);
Assert.AreEqual(original, roundTripped);
Assert.AreEqual(original.Name, roundTripped.Name);
Assert.AreEqual(original.Description, roundTripped.Description);
Assert.AreEqual(2, roundTripped.ChordCount);
Assert.AreEqual(Chord.Parse("Ctrl+S"), roundTripped.GetChord("file.save"));
Assert.AreEqual(Chord.Parse("Ctrl+C"), roundTripped.GetChord("edit.copy"));
}
}
7 changes: 7 additions & 0 deletions Keybinding/Models/Command.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace ktsu.Keybinding.Core.Models;

using System.Text.Json.Serialization;

/// <summary>
/// Represents a command that can be executed via keybindings
/// </summary>
Expand All @@ -15,6 +17,7 @@ public sealed class Command : IEquatable<Command>
/// <param name="description">Optional description of what the command does</param>
/// <param name="category">Optional category for grouping commands</param>
/// <exception cref="ArgumentException">Thrown when id or name is null or whitespace</exception>
[JsonConstructor]
public Command(CommandId id, CommandName name, CommandDescription? description = null, CommandCategory? category = null)
{
Ensure.NotNull(id);
Expand Down Expand Up @@ -64,21 +67,25 @@ public Command(string id, string name, string? description = null, string? categ
/// <summary>
/// Gets the unique identifier for the command
/// </summary>
[JsonConverter(typeof(SemanticStringJsonConverter<CommandId>))]
public CommandId Id { get; }

/// <summary>
/// Gets the display name of the command
/// </summary>
[JsonConverter(typeof(SemanticStringJsonConverter<CommandName>))]
public CommandName Name { get; }

/// <summary>
/// Gets the description of what the command does
/// </summary>
[JsonConverter(typeof(SemanticStringJsonConverter<CommandDescription>))]
public CommandDescription? Description { get; }

/// <summary>
/// Gets the category for grouping commands
/// </summary>
[JsonConverter(typeof(SemanticStringJsonConverter<CommandCategory>))]
public CommandCategory? Category { get; }

/// <summary>
Expand Down
16 changes: 16 additions & 0 deletions Keybinding/Models/MusicalTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ public Chord(Note note)
_notes = [note];
}

// The serializer binds constructor parameters by name and exact type, so it needs a constructor that
// takes the Notes property's own type.
[JsonConstructor]
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "Called by System.Text.Json through [JsonConstructor].")]
private Chord(IReadOnlyList<Note> notes) : this((IEnumerable<Note>)notes)
{
}

/// <summary>
/// Gets all notes in this chord
/// </summary>
Expand Down Expand Up @@ -367,6 +375,14 @@ public Phrase(Chord chord) : this([chord])
{
}

// The serializer binds constructor parameters by name and exact type, so it needs a constructor that
// takes the Sequence property's own type.
[JsonConstructor]
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "Called by System.Text.Json through [JsonConstructor].")]
private Phrase(IReadOnlyList<Chord> sequence) : this((IEnumerable<Chord>)sequence)
{
}

/// <summary>
/// Gets the sequence of chords in this phrase
/// </summary>
Expand Down
16 changes: 16 additions & 0 deletions Keybinding/Models/Profile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ktsu.Keybinding.Core.Models;

using System.Collections.Generic;
using System.Text.Json.Serialization;

/// <summary>
/// Represents a keybinding profile with command to chord mappings
Expand Down Expand Up @@ -33,6 +34,21 @@
Description = description?.Trim();
}

// The Chords property is get-only, so the serializer can only restore the bindings through a
// constructor parameter of the same name and type. Without it a round-tripped profile comes back empty.
[JsonConstructor]
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "Called by System.Text.Json through [JsonConstructor].")]
private Profile(string id, string name, string? description, Dictionary<string, Chord>? chords) : this(id, name, description)
{
if (chords is not null)
{
foreach (KeyValuePair<string, Chord> binding in chords)
{
SetChord(binding.Key, binding.Value);
}
}
}

[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0032:Use auto property", Justification = "The only property over this field is obsolete, and the field is what the lock guards.")]
private readonly Dictionary<string, Chord> _chords = [];

Expand Down Expand Up @@ -80,7 +96,7 @@
/// thread-safe. Use <see cref="GetAllChords"/>, <see cref="SetChord"/>, <see cref="RemoveChord"/>
/// and <see cref="ClearChords"/> instead.
/// </remarks>
[Obsolete("Chords is not thread-safe. Use GetAllChords, SetChord, RemoveChord or ClearChords instead.")]

Check warning on line 99 in Keybinding/Models/Profile.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 99 in Keybinding/Models/Profile.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 99 in Keybinding/Models/Profile.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 99 in Keybinding/Models/Profile.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 99 in Keybinding/Models/Profile.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not forget to remove this deprecated code someday.
public Dictionary<string, Chord> Chords => _chords;

/// <summary>
Expand Down Expand Up @@ -108,7 +124,7 @@
{
if (string.IsNullOrWhiteSpace(commandId))
{
throw new ArgumentException("Command ID cannot be null or whitespace", nameof(commandId));

Check warning on line 127 in Keybinding/Models/Profile.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Command ID cannot be null or whitespace' 4 times.

Check warning on line 127 in Keybinding/Models/Profile.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Command ID cannot be null or whitespace' 4 times.

Check warning on line 127 in Keybinding/Models/Profile.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Command ID cannot be null or whitespace' 4 times.

Check warning on line 127 in Keybinding/Models/Profile.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Command ID cannot be null or whitespace' 4 times.

Check warning on line 127 in Keybinding/Models/Profile.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Command ID cannot be null or whitespace' 4 times.
}

Ensure.NotNull(chord);
Expand Down
45 changes: 45 additions & 0 deletions Keybinding/Models/SemanticStringJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Keybinding.Core.Models;

using System.Text.Json;
using System.Text.Json.Serialization;
using ktsu.Semantics.Strings;

/// <summary>
/// Serializes a semantic string as a plain JSON string. Without it System.Text.Json treats the
/// semantic string as a collection of chars, writing an array it cannot read back. Reading goes
/// through <see cref="SemanticString{TDerived}.Create(string)"/>, so the type's validation still applies.
/// </summary>
/// <typeparam name="T">The semantic string type</typeparam>
internal sealed class SemanticStringJsonConverter<T> : JsonConverter<T>
where T : SemanticString<T>
{
/// <inheritdoc/>
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException($"Expected a string for {typeof(T).Name} but found {reader.TokenType}.");
}

string value = reader.GetString()!;
try
{
return SemanticString<T>.Create(value);
}
catch (ArgumentException ex)
{
throw new JsonException($"'{value}' is not a valid {typeof(T).Name}.", ex);
}
}

/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
Ensure.NotNull(writer);
Ensure.NotNull(value);

writer.WriteStringValue(value.ToString());
}
}
Loading