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
68 changes: 68 additions & 0 deletions UndoRedo.Test/UndoRedoStackTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,66 @@
Assert.IsTrue(navigationProvider.WasCancelled, "Navigation should have been cancelled due to timeout");
}

[TestMethod]
public async Task UndoAsync_NavigationProviderThrows_ReturnsTrueWithUndoApplied()
{
// Arrange
UndoRedoService stack = CreateService();
stack.SetNavigationProvider(new ThrowingNavigationProvider());
int value = 0;
stack.Execute(new DelegateCommand("Increment", () => value++, () => value--, navigationContext: "editor"));

// Act
bool result = await stack.UndoAsync().ConfigureAwait(false);

Check warning on line 551 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=AaDhkXKnEGH9SgCwbcd0&open=AaDhkXKnEGH9SgCwbcd0&pullRequest=100

// Assert
Assert.IsTrue(result, "UndoAsync should report the undo, which was applied before navigation failed");
Assert.AreEqual(0, value);
Assert.AreEqual(-1, stack.CurrentPosition);
}

[TestMethod]
public async Task RedoAsync_NavigationProviderThrows_ReturnsTrueWithRedoApplied()
{
// Arrange
UndoRedoService stack = CreateService();
stack.SetNavigationProvider(new ThrowingNavigationProvider());
int value = 0;
stack.Execute(new DelegateCommand("Increment", () => value++, () => value--, navigationContext: "editor"));
await stack.UndoAsync(navigateToChange: false).ConfigureAwait(false);

Check warning on line 567 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=AaDhkXKnEGH9SgCwbcd1&open=AaDhkXKnEGH9SgCwbcd1&pullRequest=100

// Act
bool result = await stack.RedoAsync().ConfigureAwait(false);

Check warning on line 570 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=AaDhkXKnEGH9SgCwbcd2&open=AaDhkXKnEGH9SgCwbcd2&pullRequest=100

// Assert
Assert.IsTrue(result, "RedoAsync should report the redo, which was applied before navigation failed");
Assert.AreEqual(1, value);
Assert.AreEqual(0, stack.CurrentPosition);
}

[TestMethod]
public async Task UndoToSaveBoundaryAsync_NavigationProviderThrows_ReturnsTrueAtBoundary()
{
// Arrange
UndoRedoService stack = CreateService();
stack.SetNavigationProvider(new ThrowingNavigationProvider());
int value = 0;
stack.Execute(new DelegateCommand("Increment", () => value++, () => value--, navigationContext: "editor"));
stack.MarkAsSaved();
stack.Execute(new DelegateCommand("Increment", () => value++, () => value--, navigationContext: "editor"));
stack.Execute(new DelegateCommand("Increment", () => value++, () => value--, navigationContext: "editor"));
SaveBoundary boundary = stack.SaveBoundaries[0];

// Act
bool result = await stack.UndoToSaveBoundaryAsync(boundary).ConfigureAwait(false);

Check warning on line 592 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=AaDhkXKnEGH9SgCwbcd3&open=AaDhkXKnEGH9SgCwbcd3&pullRequest=100

// Assert
Assert.IsTrue(result, "UndoToSaveBoundaryAsync should report the undo, which was applied before navigation failed");
Assert.AreEqual(1, value);
Assert.AreEqual(0, stack.CurrentPosition);
Assert.IsFalse(stack.HasUnsavedChanges, "The stack should be back at the save boundary");
}

[TestMethod]
public void Execute_CommandThrowsException_DoesNotCorruptStack()
{
Expand Down Expand Up @@ -1049,6 +1109,14 @@
}
}

private sealed class ThrowingNavigationProvider : INavigationProvider
{
public Task<bool> NavigateToAsync(string context, CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("editor closed");

public bool IsValidContext(string context) => true;
}

private sealed class SlowNavigationProvider : INavigationProvider
{
public bool WasCancelled { get; private set; }
Expand Down
70 changes: 33 additions & 37 deletions UndoRedo/Services/UndoRedoService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,19 +165,9 @@
_stackManager.MovePrevious();
CommandUndone?.Invoke(this, new CommandUndoneEventArgs(command, _stackManager.CurrentPosition));

if (navigateToChange && _options.EnableNavigation && _navigationProvider != null && !string.IsNullOrEmpty(command.NavigationContext))
if (navigateToChange)
{
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.EffectiveNavigationTimeout);

try
{
await _navigationProvider.NavigateToAsync(command.NavigationContext!, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Navigation timeout or cancellation - not critical
}
await NavigateSafelyAsync(command.NavigationContext, cancellationToken).ConfigureAwait(false);
}

return true;
Expand All @@ -201,19 +191,9 @@
_stackManager.MoveNext();
CommandRedone?.Invoke(this, new CommandRedoneEventArgs(command, _stackManager.CurrentPosition));

if (navigateToChange && _options.EnableNavigation && _navigationProvider != null && !string.IsNullOrEmpty(command.NavigationContext))
if (navigateToChange)
{
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.EffectiveNavigationTimeout);

try
{
await _navigationProvider.NavigateToAsync(command.NavigationContext!, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Navigation timeout or cancellation - not critical
}
await NavigateSafelyAsync(command.NavigationContext, cancellationToken).ConfigureAwait(false);
}

return true;
Expand Down Expand Up @@ -266,25 +246,41 @@
CommandUndone?.Invoke(this, new CommandUndoneEventArgs(command, _stackManager.CurrentPosition));
}

if (navigateToLastChange && lastCommand != null && _options.EnableNavigation && _navigationProvider != null &&
!string.IsNullOrEmpty(lastCommand.NavigationContext))
if (navigateToLastChange && lastCommand != null)
{
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.EffectiveNavigationTimeout);

try
{
await _navigationProvider.NavigateToAsync(lastCommand.NavigationContext!, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Navigation timeout or cancellation - not critical
}
await NavigateSafelyAsync(lastCommand.NavigationContext, cancellationToken).ConfigureAwait(false);
}

return true;
}

/// <summary>
/// Navigates to where a change was made, after the undo or redo has already been applied.
/// Navigation is best effort: any failure is swallowed, because an exception here would tell the
/// caller the undo or redo did not happen when it did, and a retry would then undo a second command.
/// </summary>
private async Task NavigateSafelyAsync(string? navigationContext, CancellationToken cancellationToken)
{
if (!_options.EnableNavigation || _navigationProvider == null || string.IsNullOrEmpty(navigationContext))
{
return;
}

using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.EffectiveNavigationTimeout);

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

Check warning on line 274 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 274 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 274 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 274 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 274 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 274 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 274 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 274 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 274 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 274 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 274 in UndoRedo/Services/UndoRedoService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_UndoRedo&issues=AaDhkXG0EGH9SgCwbcdz&open=AaDhkXG0EGH9SgCwbcdz&pullRequest=100
}
#pragma warning disable CA1031 // Do not catch general exception types
catch (Exception)
{
// Navigation timeout, cancellation or provider failure - not critical
}
#pragma warning restore CA1031 // Do not catch general exception types
}

/// <inheritdoc />
public IEnumerable<ChangeVisualization> GetChangeVisualizations(int maxItems = 50)
{
Expand Down
Loading