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

namespace ktsu.Keybinding.Test;

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

[TestClass]
public class NoteJsonSerializationTests
{
private static readonly JsonSerializerOptions CamelCaseOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };

[TestMethod]
public void Serialize_WritesKeyAsString()
{
Assert.AreEqual("{\"Key\":\"CTRL\"}", JsonSerializer.Serialize(new Note("Ctrl")));
}

[TestMethod]
public void Serialize_HonorsPropertyNamingPolicy()
{
Assert.AreEqual("{\"key\":\"CTRL\"}", JsonSerializer.Serialize(new Note("Ctrl"), CamelCaseOptions));
}

[TestMethod]
public void RoundTrip_ReturnsEqualNote()
{
Note original = new("F5");
Note? roundTripped = JsonSerializer.Deserialize<Note>(JsonSerializer.Serialize(original));
Assert.AreEqual(original, roundTripped);
}

[TestMethod]
public void Deserialize_LowercaseKey_ReturnsCanonicalNote()
{
Assert.AreEqual(new Note("Ctrl"), JsonSerializer.Deserialize<Note>("{\"Key\":\"ctrl\"}"));
}

[TestMethod]
public void Deserialize_AliasKey_ReturnsCanonicalNote()
{
Note? note = JsonSerializer.Deserialize<Note>("{\"Key\":\"Control\"}");
Assert.AreEqual("CTRL", note?.ToString());
}

[TestMethod]
public void Deserialize_CamelCasePropertyName_ReturnsNote()
{
Assert.AreEqual(new Note("Alt"), JsonSerializer.Deserialize<Note>("{\"key\":\"alt\"}"));
}

[TestMethod]
public void Deserialize_IgnoresUnknownProperties()
{
Assert.AreEqual(new Note("S"), JsonSerializer.Deserialize<Note>("{\"Other\":[1,{\"a\":2}],\"Key\":\"s\"}"));
}

[TestMethod]
public void Deserialize_Null_ReturnsNull()
{
Assert.IsNull(JsonSerializer.Deserialize<Note>("null"));
}

[TestMethod]
public void Deserialize_InsideCollection_RoundTrips()
{
List<Note> notes = [new("Ctrl"), new("Shift"), new("S")];
List<Note>? roundTripped = JsonSerializer.Deserialize<List<Note>>(JsonSerializer.Serialize(notes));
Assert.AreSequenceEqual(notes, roundTripped);
}

[TestMethod]
public void Deserialize_MissingKey_ThrowsJsonException()
{
Assert.ThrowsExactly<JsonException>(() => JsonSerializer.Deserialize<Note>("{}"));
}

[TestMethod]
public void Deserialize_BlankKey_ThrowsJsonException()
{
Assert.ThrowsExactly<JsonException>(() => JsonSerializer.Deserialize<Note>("{\"Key\":\" \"}"));
}

[TestMethod]
public void Deserialize_NonObject_ThrowsJsonException()
{
Assert.ThrowsExactly<JsonException>(() => JsonSerializer.Deserialize<Note>("[\"CTRL\"]"));
}
}
1 change: 1 addition & 0 deletions Keybinding/Models/MusicalTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace ktsu.Keybinding.Core.Models;
/// <summary>
/// Represents a musical note - a single key
/// </summary>
[JsonConverter(typeof(NoteJsonConverter))]
public sealed class Note : IEquatable<Note>
{
/// <summary>
Expand Down
80 changes: 80 additions & 0 deletions Keybinding/Models/NoteJsonConverter.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.Core.Models;

using System.Text.Json;
using System.Text.Json.Serialization;

/// <summary>
/// Serializes a <see cref="Note"/> as <c>{"Key":"CTRL"}</c>. Without it System.Text.Json treats the
/// <see cref="NoteName"/> key as a collection of chars, writing an array it cannot read back. Reading goes
/// through <see cref="Note(string)"/>, so lowercase and alias input comes back as the canonical note.
/// </summary>
internal sealed class NoteJsonConverter : JsonConverter<Note>
{
private const string KeyPropertyName = nameof(Note.Key);

/// <inheritdoc/>
public override Note Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException($"Expected an object for {nameof(Note)} but found {reader.TokenType}.");
}

string? key = null;
bool foundKey = false;
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
{
return foundKey
? CreateNote(key!)
: throw new JsonException($"{nameof(Note)} is missing its {KeyPropertyName} property.");
}

string propertyName = reader.GetString()!;
reader.Read();
if (string.Equals(propertyName, KeyPropertyName, StringComparison.OrdinalIgnoreCase))
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException($"Expected a string for {nameof(Note)}.{KeyPropertyName} but found {reader.TokenType}.");
}

key = reader.GetString();
foundKey = true;
}
else
{
reader.Skip();
}
}

throw new JsonException($"Unexpected end of JSON while reading a {nameof(Note)}.");
}

private static Note CreateNote(string key)
{
try
{
return new Note(key);
}
catch (ArgumentException ex)
{
throw new JsonException($"'{key}' is not a valid {nameof(Note)} key.", ex);
}
}

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

writer.WriteStartObject();
writer.WriteString(options.PropertyNamingPolicy?.ConvertName(KeyPropertyName) ?? KeyPropertyName, value.Key.ToString());
writer.WriteEndObject();
}
}
Loading