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
105 changes: 105 additions & 0 deletions UndoRedo.Test/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,111 @@
Assert.AreEqual(0, stack.SaveBoundaries[0].Position, "A failed load should keep the existing save boundary position");
}

private const string MalformedAssemblyName = "malformed assembly name";
private const string InvalidVersion = "invalid assembly version";
private const string NotACommand = "serializable type that is not a command";
private const string DataParseFailure = "command data its parser rejects";

private static async Task<byte[]> SerializeWithCommandTypeAsync(string caseName)
{
string type = caseName switch
{
MalformedAssemblyName => "Foo, =bad",
InvalidVersion => "Foo, Bar, Version=abc",
NotACommand => typeof(SerializableNonCommand).AssemblyQualifiedName!,
DataParseFailure => typeof(IntParsingSerializableCommand).AssemblyQualifiedName!,
_ => throw new ArgumentOutOfRangeException(nameof(caseName)),
};

JsonUndoRedoSerializer serializer = new();
byte[] data = await serializer.SerializeAsync([new TestSerializableCommand("saved")], 0, []).ConfigureAwait(false);
System.Text.Json.Nodes.JsonNode root = System.Text.Json.Nodes.JsonNode.Parse(data)!;
System.Text.Json.Nodes.JsonObject command = root["commands"]![0]!.AsObject();
string typeKey = command.Single(p => p.Key.Equals("type", StringComparison.OrdinalIgnoreCase)).Key;
string dataKey = command.Single(p => p.Key.Equals("data", StringComparison.OrdinalIgnoreCase)).Key;
command[typeKey] = type;
command[dataKey] = "abc";
return System.Text.Encoding.UTF8.GetBytes(root.ToJsonString());
}

[TestMethod]
[DataRow(MalformedAssemblyName)]
[DataRow(InvalidVersion)]
[DataRow(NotACommand)]
[DataRow(DataParseFailure)]
public async Task JsonSerializer_DeserializeUnloadableCommand_ThrowsInvalidOperationException(string caseName)
{
// Arrange
byte[] data = await SerializeWithCommandTypeAsync(caseName).ConfigureAwait(false);
JsonUndoRedoSerializer serializer = new();

// Act & Assert: every way a command can fail to load is reported through the deserialization
// contract rather than as the raw reflection, cast or parse error
await Assert.ThrowsExactlyAsync<InvalidOperationException>(
() => serializer.DeserializeAsync(data)).ConfigureAwait(false);

Check warning on line 589 in UndoRedo.Test/SerializationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_UndoRedo&issues=AaDfQFGZJ46FyAMTWI3R&open=AaDfQFGZJ46FyAMTWI3R&pullRequest=99
}

[TestMethod]
[DataRow(MalformedAssemblyName)]
[DataRow(InvalidVersion)]
[DataRow(NotACommand)]
[DataRow(DataParseFailure)]
public async Task UndoRedoService_LoadStateUnloadableCommand_ReturnsFalseAndKeepsHistory(string caseName)
{
// Arrange
byte[] data = await SerializeWithCommandTypeAsync(caseName).ConfigureAwait(false);
UndoRedoService stack = CreateService();
stack.SetSerializer(new JsonUndoRedoSerializer());
stack.Execute(new DelegateCommand("A", () => { }, () => { }));

// Act
bool success = await stack.LoadStateAsync(data).ConfigureAwait(false);

Check warning on line 606 in UndoRedo.Test/SerializationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_UndoRedo&issues=AaDfQFGZJ46FyAMTWI3S&open=AaDfQFGZJ46FyAMTWI3S&pullRequest=99

// Assert
Assert.IsFalse(success, "LoadStateAsync should return false when a command cannot be loaded");
Assert.AreEqual(1, stack.CommandCount, "A failed load should keep the existing commands");
Assert.AreEqual("A", stack.Commands[0].Description);
}

#pragma warning disable CA1812 // Instantiated by reflection during deserialization
private sealed class SerializableNonCommand : ISerializableCommand
{
public string SerializeData() => string.Empty;

public void DeserializeData(string data)
{
// Nothing to restore
}
}
#pragma warning restore CA1812

#pragma warning disable CA1812 // Instantiated by reflection during deserialization
private sealed class IntParsingSerializableCommand : BaseCommand, ISerializableCommand
{
public IntParsingSerializableCommand() : base(ChangeType.Modify, ["test"])
{
}

public int Value { get; private set; }

public override string Description => $"Int command with value: {Value}";

public override void Execute()
{
// Test implementation
}

public override void Undo()
{
// Test implementation
}

public string SerializeData() => Value.ToString(System.Globalization.CultureInfo.InvariantCulture);

public void DeserializeData(string data) => Value = int.Parse(data, System.Globalization.CultureInfo.InvariantCulture);
}
#pragma warning restore CA1812

private sealed class ConstructorOnlySerializableCommand(string value)
: BaseCommand(ChangeType.Modify, ["test"]), ISerializableCommand
{
Expand Down
38 changes: 36 additions & 2 deletions UndoRedo/Services/JsonUndoRedoSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,15 @@
}

// For commands that implement ISerializableCommand, try to reconstruct them
Type? commandType = Type.GetType(serializableCommand.Type);
Type? commandType = ResolveCommandType(serializableCommand.Type);
if (commandType != null && typeof(ISerializableCommand).IsAssignableFrom(commandType))
{
if (!typeof(ICommand).IsAssignableFrom(commandType))
{
throw new InvalidOperationException(
$"Cannot reconstruct command type '{commandType.FullName}': it implements {nameof(ISerializableCommand)} but not {nameof(ICommand)}.");
}

ISerializableCommand? instance;
try
{
Expand All @@ -163,14 +169,42 @@
ex);
}

instance?.DeserializeData(serializableCommand.Data!);
try
{
instance?.DeserializeData(serializableCommand.Data!);

Check warning on line 174 in UndoRedo/Services/JsonUndoRedoSerializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 174 in UndoRedo/Services/JsonUndoRedoSerializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.
}
#pragma warning disable CA1031 // Do not catch general exception types
catch (Exception ex) when (ex is not OperationCanceledException)
#pragma warning restore CA1031 // Do not catch general exception types
{
// DeserializeData is the command's own parser, so it can throw anything. Report it
// through the deserialization contract so LoadStateAsync returns false.
throw new InvalidOperationException(
$"Cannot reconstruct command type '{commandType.FullName}': its {nameof(ISerializableCommand.DeserializeData)} rejected the saved data.",
ex);
}

return (ICommand)instance!;
}

// Fallback to placeholder
return new PlaceholderCommand(serializableCommand.Description, serializableCommand.NavigationContext, serializableCommand.Metadata);
}

private static Type? ResolveCommandType(string typeName)
{
try
{
return Type.GetType(typeName);
}
catch (Exception ex) when (ex is IOException or BadImageFormatException or ArgumentException or TypeLoadException)
{
// Type.GetType returns null for a type it cannot find, but still throws for a malformed
// assembly-qualified name or an assembly that fails to load.
throw new InvalidOperationException($"Cannot resolve command type '{typeName}'.", ex);
}
}

/// <summary>
/// Serializable representation of a command
/// </summary>
Expand Down
Loading