feat(01-06): implement FeatureViewModelBase with async/cancel/progress pattern

- Add ProgressUpdatedMessage ValueChangedMessage for StatusBar live updates
- Add FeatureViewModelBase with CancellationTokenSource lifecycle, IsRunning,
  IProgress<OperationProgress>, OperationCanceledException handling
- Add 6 unit tests covering lifecycle, progress, cancellation, error handling
  and CanExecute guard
This commit is contained in:
Dev
2026-04-02 12:29:38 +02:00
parent fcae8f0e49
commit 3c09155648
3 changed files with 211 additions and 2 deletions

View File

@@ -1,7 +1,125 @@
using Microsoft.Extensions.Logging.Abstractions;
using SharepointToolbox.Core.Models;
using SharepointToolbox.ViewModels;
namespace SharepointToolbox.Tests.ViewModels;
[Trait("Category", "Unit")]
public class FeatureViewModelBaseTests
{
[Fact(Skip = "Wave 0 stub — implemented in plan 01-06")]
public void CancelCommand_Cancels_RunningOperation() { }
private class TestViewModel : FeatureViewModelBase
{
public TestViewModel() : base(NullLogger<FeatureViewModelBase>.Instance) { }
public Func<CancellationToken, IProgress<OperationProgress>, Task>? OperationFunc { get; set; }
protected override Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
=> OperationFunc?.Invoke(ct, progress) ?? Task.CompletedTask;
}
[Fact]
public async Task IsRunning_IsTrueWhileOperationExecutes_ThenFalseAfterCompletion()
{
var vm = new TestViewModel();
var tcs = new TaskCompletionSource<bool>();
bool wasRunningDuringOperation = false;
vm.OperationFunc = async (ct, p) =>
{
wasRunningDuringOperation = vm.IsRunning;
await tcs.Task;
};
var runTask = vm.RunCommand.ExecuteAsync(null);
// Give run task time to start
await Task.Delay(10);
Assert.True(wasRunningDuringOperation);
tcs.SetResult(true);
await runTask;
Assert.False(vm.IsRunning);
}
[Fact]
public async Task ProgressValue_AndStatusMessage_UpdateViaIProgress()
{
var vm = new TestViewModel();
vm.OperationFunc = async (ct, progress) =>
{
progress.Report(new OperationProgress(50, 100, "halfway"));
await Task.Yield();
};
await vm.RunCommand.ExecuteAsync(null);
// Allow dispatcher to process
await Task.Delay(20);
Assert.Equal(50, vm.ProgressValue);
Assert.Equal("halfway", vm.StatusMessage);
}
[Fact]
public async Task CancelCommand_DuringOperation_SetsStatusMessageToCancelled()
{
var vm = new TestViewModel();
var started = new TaskCompletionSource<bool>();
vm.OperationFunc = async (ct, p) =>
{
started.SetResult(true);
await Task.Delay(5000, ct); // Will be cancelled
};
var runTask = vm.RunCommand.ExecuteAsync(null);
await started.Task;
vm.CancelCommand.Execute(null);
await runTask;
Assert.Contains("cancel", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
Assert.False(vm.IsRunning);
}
[Fact]
public async Task OperationCanceledException_IsCaughtGracefully_IsRunningBecomesFalse()
{
var vm = new TestViewModel();
vm.OperationFunc = (ct, p) => throw new OperationCanceledException();
// Should not throw
await vm.RunCommand.ExecuteAsync(null);
Assert.False(vm.IsRunning);
}
[Fact]
public async Task ExceptionDuringOperation_SetsStatusMessageToErrorText_IsRunningBecomesFalse()
{
var vm = new TestViewModel();
vm.OperationFunc = (ct, p) => throw new InvalidOperationException("test error");
await vm.RunCommand.ExecuteAsync(null);
Assert.False(vm.IsRunning);
Assert.Contains("test error", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RunCommand_CannotBeInvoked_WhileIsRunning()
{
var vm = new TestViewModel();
var tcs = new TaskCompletionSource<bool>();
vm.OperationFunc = async (ct, p) => await tcs.Task;
var runTask = vm.RunCommand.ExecuteAsync(null);
await Task.Delay(10); // Let it start
Assert.False(vm.RunCommand.CanExecute(null));
tcs.SetResult(true);
await runTask;
Assert.True(vm.RunCommand.CanExecute(null));
}
}