diff --git a/UndoRedo.Test/SerializationTests.cs b/UndoRedo.Test/SerializationTests.cs index d3aa593..7e08b3f 100644 --- a/UndoRedo.Test/SerializationTests.cs +++ b/UndoRedo.Test/SerializationTests.cs @@ -545,6 +545,29 @@ [new TestSerializableCommand("X")], Assert.AreEqual(0, stack.SaveBoundaries[0].Position, "A failed load should keep the existing save boundary position"); } + [TestMethod] + public async Task UndoRedoService_SaveLoadState_ReconstructsCommandWithEmptyData() + { + // Arrange + UndoRedoService stack = CreateService(); + stack.SetSerializer(new JsonUndoRedoSerializer()); + stack.Execute(new EmptyDataSerializableCommand()); + + // Act + byte[] data = await stack.SaveStateAsync().ConfigureAwait(false); + + UndoRedoService newStack = CreateService(); + newStack.SetSerializer(new JsonUndoRedoSerializer()); + bool success = await newStack.LoadStateAsync(data).ConfigureAwait(false); + + // Assert: SerializeData() returning "" is a normal result for a command with no parameters, + // so the command is rebuilt rather than replaced with an un-undoable placeholder + Assert.IsTrue(success); + Assert.IsInstanceOfType(newStack.Commands[0]); + Assert.IsTrue(await newStack.UndoAsync().ConfigureAwait(false)); + Assert.AreEqual(-1, newStack.CurrentPosition); + } + 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"; @@ -650,6 +673,32 @@ public override void Undo() } #pragma warning restore CA1812 + private sealed class EmptyDataSerializableCommand : BaseCommand, ISerializableCommand + { + public EmptyDataSerializableCommand() : base(ChangeType.Modify, ["test"]) + { + } + + public override string Description => "Clear all"; + + public override void Execute() + { + // Test implementation + } + + public override void Undo() + { + // Test implementation + } + + public string SerializeData() => string.Empty; + + public void DeserializeData(string data) + { + // Nothing to restore + } + } + private sealed class ConstructorOnlySerializableCommand(string value) : BaseCommand(ChangeType.Modify, ["test"]), ISerializableCommand { diff --git a/UndoRedo/Services/JsonUndoRedoSerializer.cs b/UndoRedo/Services/JsonUndoRedoSerializer.cs index fd6a895..cd57415 100644 --- a/UndoRedo/Services/JsonUndoRedoSerializer.cs +++ b/UndoRedo/Services/JsonUndoRedoSerializer.cs @@ -138,7 +138,10 @@ private static ICommand ConvertFromSerializableCommand(SerializableCommand seria { // This is a simplified approach - real implementations would need a factory pattern // or registry to recreate commands from serialized data - if (string.IsNullOrEmpty(serializableCommand.Data)) + // A null Data is what ConvertToSerializableCommand writes for a command that is not an + // ISerializableCommand. An empty string is not that marker: it is a normal SerializeData() + // result for a command with no parameters, and such a command is reconstructed below. + if (serializableCommand.Data is null) { // Return a placeholder command that can't execute but preserves metadata return new PlaceholderCommand(serializableCommand.Description, serializableCommand.NavigationContext, serializableCommand.Metadata); @@ -171,7 +174,7 @@ private static ICommand ConvertFromSerializableCommand(SerializableCommand seria try { - instance?.DeserializeData(serializableCommand.Data!); + instance?.DeserializeData(serializableCommand.Data); } #pragma warning disable CA1031 // Do not catch general exception types catch (Exception ex) when (ex is not OperationCanceledException)