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
93 changes: 93 additions & 0 deletions UndoRedo.Test/UndoRedoStackTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,99 @@
Assert.IsTrue(stack.CanUndo, "CanUndo should remain true after a failed merge");
}

[TestMethod]
public void Undo_CommandThrowsException_LeavesPositionUnchanged()
{
// Arrange: B's Undo fails the first time it is attempted
UndoRedoService stack = CreateService();
int value = 0;
bool failUndo = true;

stack.Execute(new DelegateCommand("A", () => value = 1, () => value = 0));
stack.Execute(new DelegateCommand("B", () => value = 2, () =>
{
if (failUndo)
{
throw new InvalidOperationException();
}

value = 1;
}));

// Act
Assert.ThrowsExactly<InvalidOperationException>(() => stack.Undo());

// Assert: nothing was undone, so the stack must not have moved
Assert.AreEqual(1, stack.CurrentPosition, "A failed Undo must not move the stack position");
Assert.IsTrue(stack.CanUndo, "CanUndo should be unchanged after a failed Undo");
Assert.IsFalse(stack.CanRedo, "A command that failed to undo must not become redoable");
Assert.AreEqual(2, value);

// And the next Undo must retry B rather than skip over it to A
failUndo = false;
stack.Undo();
Assert.AreEqual(1, value, "Undo after a failed Undo must undo B, not skip to A");
Assert.AreEqual(0, stack.CurrentPosition);
}

[TestMethod]
public void Redo_CommandThrowsException_LeavesPositionUnchanged()
{
// Arrange: A's Execute fails once it is redone
UndoRedoService stack = CreateService();
int value = 0;
bool failExecute = false;

stack.Execute(new DelegateCommand("A", () =>
{
if (failExecute)
{
throw new InvalidOperationException();
}

value = 1;
}, () => value = 0));
stack.Undo();
failExecute = true;

// Act
Assert.ThrowsExactly<InvalidOperationException>(() => stack.Redo());

// Assert: nothing was reapplied, so the stack must not have moved
Assert.AreEqual(-1, stack.CurrentPosition, "A failed Redo must not move the stack position");
Assert.IsFalse(stack.CanUndo, "A command that failed to redo must not become undoable");
Assert.IsTrue(stack.CanRedo, "The command that failed to redo must still be redoable");
Assert.AreEqual(0, value);

// And the failed command can be redone once it succeeds
failExecute = false;
stack.Redo();
Assert.AreEqual(1, value);
Assert.AreEqual(0, stack.CurrentPosition);
}

[TestMethod]
public async Task UndoToSaveBoundary_CommandThrowsException_StopsWithAccuratePosition()
{
// Arrange: save at A, then B and C, where B's Undo throws
UndoRedoService stack = CreateService();
int value = 0;

stack.Execute(new DelegateCommand("A", () => value = 1, () => value = 0));
stack.MarkAsSaved("after A");
stack.Execute(new DelegateCommand("B", () => value = 2, () => throw new InvalidOperationException()));
stack.Execute(new DelegateCommand("C", () => value = 3, () => value = 2));

// Act
await Assert.ThrowsExactlyAsync<InvalidOperationException>(() =>
stack.UndoToSaveBoundaryAsync(stack.SaveBoundaries[0], navigateToLastChange: false)).ConfigureAwait(false);

Check warning on line 633 in UndoRedo.Test/UndoRedoStackTests.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=AaDbkWkMxjvAjhH7VxPn&open=AaDbkWkMxjvAjhH7VxPn&pullRequest=78

// Assert: C was undone, B was not, and the position says exactly that
Assert.AreEqual(2, value);
Assert.AreEqual(1, stack.CurrentPosition, "The position must stop on the command that failed to undo");
Assert.IsTrue(stack.CanRedo, "C was undone, so it must be redoable");
}

[TestMethod]
public async Task UndoToSaveBoundary_WhenAlreadyAtPosition_ReturnsFalse()
{
Expand Down
16 changes: 12 additions & 4 deletions UndoRedo/Services/UndoRedoService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,16 @@
/// <inheritdoc />
public async Task<bool> UndoAsync(bool navigateToChange = true, CancellationToken cancellationToken = default)
{
ICommand? command = _stackManager.MovePrevious();
ICommand? command = _stackManager.GetCurrentCommand();
if (command == null)
{
return false;
}

// Undo the real state change before moving the position, so a command that throws leaves the
// stack describing what is actually applied. This mirrors Execute() above.
command.Undo();
_stackManager.MovePrevious();
CommandUndone?.Invoke(this, new CommandUndoneEventArgs(command, _stackManager.CurrentPosition));

if (navigateToChange && _options.EnableNavigation && _navigationProvider != null && !string.IsNullOrEmpty(command.NavigationContext))
Expand All @@ -165,7 +168,7 @@

try
{
await _navigationProvider.NavigateToAsync(command.NavigationContext!, cts.Token).ConfigureAwait(false);

Check warning on line 171 in UndoRedo/Services/UndoRedoService.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 171 in UndoRedo/Services/UndoRedoService.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 171 in UndoRedo/Services/UndoRedoService.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 171 in UndoRedo/Services/UndoRedoService.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.
}
catch (OperationCanceledException)
{
Expand All @@ -182,13 +185,16 @@
/// <inheritdoc />
public async Task<bool> RedoAsync(bool navigateToChange = true, CancellationToken cancellationToken = default)
{
ICommand? command = _stackManager.MoveNext();
if (command == null)
if (!_stackManager.CanRedo)
{
return false;
}

// Reapply the real state change before moving the position, so a command that throws can still
// be redone and is never undone without having been applied. This mirrors Execute() above.
ICommand command = _stackManager.Commands[_stackManager.CurrentPosition + 1];
command.Execute();
_stackManager.MoveNext();
CommandRedone?.Invoke(this, new CommandRedoneEventArgs(command, _stackManager.CurrentPosition));

if (navigateToChange && _options.EnableNavigation && _navigationProvider != null && !string.IsNullOrEmpty(command.NavigationContext))
Expand All @@ -198,7 +204,7 @@

try
{
await _navigationProvider.NavigateToAsync(command.NavigationContext!, cts.Token).ConfigureAwait(false);

Check warning on line 207 in UndoRedo/Services/UndoRedoService.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 207 in UndoRedo/Services/UndoRedoService.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 207 in UndoRedo/Services/UndoRedoService.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 207 in UndoRedo/Services/UndoRedoService.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.
}
catch (OperationCanceledException)
{
Expand Down Expand Up @@ -243,13 +249,15 @@
ICommand? lastCommand = null;
while (_stackManager.CurrentPosition > saveBoundary.Position)
{
ICommand? command = _stackManager.MovePrevious();
ICommand? command = _stackManager.GetCurrentCommand();
if (command == null)
{
break;
}

// Stop at the first failure with the position still on the command that failed to undo
command.Undo();
_stackManager.MovePrevious();
lastCommand = command;
CommandUndone?.Invoke(this, new CommandUndoneEventArgs(command, _stackManager.CurrentPosition));
}
Expand All @@ -262,7 +270,7 @@

try
{
await _navigationProvider.NavigateToAsync(lastCommand.NavigationContext!, cts.Token).ConfigureAwait(false);

Check warning on line 273 in UndoRedo/Services/UndoRedoService.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 273 in UndoRedo/Services/UndoRedoService.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.
}
catch (OperationCanceledException)
{
Expand Down
Loading