diff --git a/UndoRedo.Test/UndoRedoStackTests.cs b/UndoRedo.Test/UndoRedoStackTests.cs index 6531888..a77381f 100644 --- a/UndoRedo.Test/UndoRedoStackTests.cs +++ b/UndoRedo.Test/UndoRedoStackTests.cs @@ -402,6 +402,55 @@ public void CommandMerging_AfterUndo_CleansInvalidSaveBoundaries() Assert.IsFalse(stack.CanRedo, "Redo should be unavailable after forward commands are cleared"); } + [TestMethod] + public void CommandMerging_AfterMarkAsSaved_KeepsTheSaveBoundary() + { + // Arrange + UndoRedoOptions options = UndoRedoOptions.Create(autoMerge: true); + UndoRedoService stack = new(new StackManager(), new SaveBoundaryManager(), new CommandMerger(), options); + List value = []; + + stack.Execute(new TestInsertMergeCommand(value, 0, "a")); + stack.MarkAsSaved(); + + // Act + stack.Execute(new TestInsertMergeCommand(value, 1, "b")); + + // Assert + Assert.AreEqual("ab", new string([.. value])); + Assert.AreEqual(2, stack.CommandCount, "An edit after a save should not merge into the saved command"); + Assert.HasCount(1, stack.SaveBoundaries, "The save boundary should survive the next edit"); + Assert.IsTrue(stack.HasUnsavedChanges, "The edit after the save should be unsaved"); + + stack.Undo(); + Assert.AreEqual("a", new string([.. value]), "Undo should return to the saved text"); + Assert.IsFalse(stack.HasUnsavedChanges, "Undo back to the save should leave the stack clean"); + } + + [TestMethod] + public void CommandMerging_AfterMarkAsSaved_MergesLaterEditsWithEachOther() + { + // Arrange + UndoRedoOptions options = UndoRedoOptions.Create(autoMerge: true); + UndoRedoService stack = new(new StackManager(), new SaveBoundaryManager(), new CommandMerger(), options); + List value = []; + + stack.Execute(new TestInsertMergeCommand(value, 0, "a")); + stack.MarkAsSaved(); + + // Act + stack.Execute(new TestInsertMergeCommand(value, 1, "b")); + stack.Execute(new TestInsertMergeCommand(value, 2, "c")); + + // Assert + Assert.AreEqual("abc", new string([.. value])); + Assert.AreEqual(2, stack.CommandCount, "Edits with no save between them should still merge"); + + stack.Undo(); + Assert.AreEqual("a", new string([.. value]), "Undo should revert the merged post-save edits together"); + Assert.IsFalse(stack.HasUnsavedChanges); + } + [TestMethod] public void CompositeCommand_NestedComposites_HandlesCorrectly() { diff --git a/UndoRedo/Services/UndoRedoService.cs b/UndoRedo/Services/UndoRedoService.cs index 6f7ed9b..8cd3fbd 100644 --- a/UndoRedo/Services/UndoRedoService.cs +++ b/UndoRedo/Services/UndoRedoService.cs @@ -70,13 +70,17 @@ public sealed class UndoRedoService( /// The navigation provider public void SetNavigationProvider(INavigationProvider? navigationProvider) => _navigationProvider = navigationProvider; + private bool IsAtSaveBoundary(int position) => _saveBoundaryManager.SaveBoundaries.Any(boundary => boundary.Position == position); + /// public void Execute(ICommand command) { Ensure.NotNull(command); - // Try to merge with the last command if auto-merge is enabled - if (_options.AutoMergeCommands && _stackManager.CanUndo) + // Try to merge with the last command if auto-merge is enabled. Never merge into a command that + // ends at a save boundary: that would fold the new edit into the saved state and delete the + // boundary, so Undo could never return to what was saved. + if (_options.AutoMergeCommands && _stackManager.CanUndo && !IsAtSaveBoundary(_stackManager.CurrentPosition)) { ICommand? lastCommand = _stackManager.GetCurrentCommand(); if (lastCommand != null && _commandMerger.CanMerge(lastCommand, command))