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
65 changes: 65 additions & 0 deletions UndoRedo.Test/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,71 @@
Assert.IsFalse(state.CanRedo, "State should not allow redo when at end of command stack");
}

[TestMethod]
public async Task UndoRedoService_LoadStateCommandHasNoParameterlessConstructor_ReturnsFalse()
{
// Arrange: a command type whose only constructor takes the value it changes, which is what a
// real ISerializableCommand implementation looks like
UndoRedoService stack = CreateService();
stack.SetSerializer(new JsonUndoRedoSerializer());
stack.Execute(new ConstructorOnlySerializableCommand("saved"));

byte[] data = await stack.SaveStateAsync().ConfigureAwait(false);

Check warning on line 335 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=AaDPlnXYX4Nr1RJLcNrP&open=AaDPlnXYX4Nr1RJLcNrP&pullRequest=73

UndoRedoService newStack = CreateService();
newStack.SetSerializer(new JsonUndoRedoSerializer());

// Act: LoadStateAsync reports failure rather than letting MissingMethodException escape
bool success = await newStack.LoadStateAsync(data).ConfigureAwait(false);

Check warning on line 341 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=AaDPlnXYX4Nr1RJLcNrQ&open=AaDPlnXYX4Nr1RJLcNrQ&pullRequest=73

// Assert
Assert.IsFalse(success, "LoadStateAsync should return false when a command cannot be reconstructed");
Assert.AreEqual(0, newStack.CommandCount, "A failed load should not leave partial state on the stack");
}

[TestMethod]
public async Task JsonSerializer_DeserializeCommandHasNoParameterlessConstructor_ThrowsInvalidOperationException()
{
// Arrange
JsonUndoRedoSerializer serializer = new();
ConstructorOnlySerializableCommand command = new("saved");
byte[] data = await serializer.SerializeAsync([command], 0, []).ConfigureAwait(false);

Check warning on line 354 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=AaDPlnXYX4Nr1RJLcNrR&open=AaDPlnXYX4Nr1RJLcNrR&pullRequest=73

// Act & Assert: the failure is reported as part of the deserialization contract, not as the
// raw reflection error
InvalidOperationException ex = await Assert.ThrowsExactlyAsync<InvalidOperationException>(
() => serializer.DeserializeAsync(data)).ConfigureAwait(false);

Check warning on line 359 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=AaDPlnXYX4Nr1RJLcNrS&open=AaDPlnXYX4Nr1RJLcNrS&pullRequest=73

Assert.Contains(nameof(ConstructorOnlySerializableCommand), ex.Message, "The message should name the type that could not be reconstructed");
Assert.IsInstanceOfType<MissingMethodException>(ex.InnerException, "The underlying reflection failure should be preserved");
}

private sealed class ConstructorOnlySerializableCommand(string value)
: BaseCommand(ChangeType.Modify, ["test"]), ISerializableCommand
{
public string Value { get; private set; } = value;

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

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

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

public string SerializeData() => JsonSerializer.Serialize(new { Value });

public void DeserializeData(string data)
{
JsonElement element = JsonSerializer.Deserialize<JsonElement>(data);
Value = element.GetProperty(nameof(Value)).GetString() ?? string.Empty;
}
}

private sealed class TestSerializableCommand : BaseCommand, ISerializableCommand
{
public string Value { get; private set; } = string.Empty;
Expand Down
21 changes: 20 additions & 1 deletion UndoRedo/Services/JsonUndoRedoSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,22 @@
Type? commandType = Type.GetType(serializableCommand.Type);
if (commandType != null && typeof(ISerializableCommand).IsAssignableFrom(commandType))
{
ISerializableCommand? instance = Activator.CreateInstance(commandType) as ISerializableCommand;
ISerializableCommand? instance;
try
{
instance = Activator.CreateInstance(commandType) as ISerializableCommand;
}
catch (MissingMethodException ex)
{
// Activator.CreateInstance needs a public parameterless constructor, which most real
// command types do not have. Translate it into an exception the deserialization
// contract already covers, so LoadStateAsync reports false instead of throwing.
throw new InvalidOperationException(
$"Cannot reconstruct command type '{commandType.FullName}': {nameof(ISerializableCommand)} implementations must have a public parameterless constructor for DeserializeData to populate.",
ex);
}

instance?.DeserializeData(serializableCommand.Data!);

Check warning on line 127 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 127 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 127 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 127 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 127 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 127 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 127 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 127 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 127 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 127 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.
return (ICommand)instance!;
}

Expand Down Expand Up @@ -146,6 +160,11 @@
/// <summary>
/// Interface for commands that can serialize their data
/// </summary>
/// <remarks>
/// Implementations must also provide a public parameterless constructor. Deserialization creates the
/// instance before it has any data to work from, then populates it through <see cref="DeserializeData"/>.
/// Without one, reconstructing the command fails and <c>LoadStateAsync</c> reports <see langword="false"/>.
/// </remarks>
public interface ISerializableCommand
{
/// <summary>
Expand Down
Loading