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:
@@ -1,7 +1,125 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.ViewModels;
|
||||||
|
|
||||||
namespace SharepointToolbox.Tests.ViewModels;
|
namespace SharepointToolbox.Tests.ViewModels;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
public class FeatureViewModelBaseTests
|
public class FeatureViewModelBaseTests
|
||||||
{
|
{
|
||||||
[Fact(Skip = "Wave 0 stub — implemented in plan 01-06")]
|
private class TestViewModel : FeatureViewModelBase
|
||||||
public void CancelCommand_Cancels_RunningOperation() { }
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Core.Messages;
|
||||||
|
|
||||||
|
public sealed class ProgressUpdatedMessage : ValueChangedMessage<OperationProgress>
|
||||||
|
{
|
||||||
|
public ProgressUpdatedMessage(OperationProgress progress) : base(progress) { }
|
||||||
|
}
|
||||||
82
SharepointToolbox/ViewModels/FeatureViewModelBase.cs
Normal file
82
SharepointToolbox/ViewModels/FeatureViewModelBase.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using SharepointToolbox.Core.Messages;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.ViewModels;
|
||||||
|
|
||||||
|
public abstract partial class FeatureViewModelBase : ObservableRecipient
|
||||||
|
{
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(CancelCommand))]
|
||||||
|
private bool _isRunning;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _statusMessage = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int _progressValue;
|
||||||
|
|
||||||
|
public IAsyncRelayCommand RunCommand { get; }
|
||||||
|
public RelayCommand CancelCommand { get; }
|
||||||
|
|
||||||
|
protected FeatureViewModelBase(ILogger<FeatureViewModelBase> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
RunCommand = new AsyncRelayCommand(ExecuteAsync, () => !IsRunning);
|
||||||
|
CancelCommand = new RelayCommand(() => _cts?.Cancel(), () => IsRunning);
|
||||||
|
IsActive = true; // Activates ObservableRecipient for WeakReferenceMessenger
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteAsync()
|
||||||
|
{
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
IsRunning = true;
|
||||||
|
StatusMessage = string.Empty;
|
||||||
|
ProgressValue = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var progress = new Progress<OperationProgress>(p =>
|
||||||
|
{
|
||||||
|
ProgressValue = p.Total > 0 ? (int)(100.0 * p.Current / p.Total) : 0;
|
||||||
|
StatusMessage = p.Message;
|
||||||
|
WeakReferenceMessenger.Default.Send(new ProgressUpdatedMessage(p));
|
||||||
|
});
|
||||||
|
await RunOperationAsync(_cts.Token, progress);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
StatusMessage = TranslationSource.Instance["status.cancelled"];
|
||||||
|
_logger.LogInformation("Operation cancelled by user.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"{TranslationSource.Instance["err.generic"]} {ex.Message}";
|
||||||
|
_logger.LogError(ex, "Operation failed.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsRunning = false;
|
||||||
|
_cts?.Dispose();
|
||||||
|
_cts = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress);
|
||||||
|
|
||||||
|
protected override void OnActivated()
|
||||||
|
{
|
||||||
|
Messenger.Register<TenantSwitchedMessage>(this, (r, m) => ((FeatureViewModelBase)r).OnTenantSwitched(m.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void OnTenantSwitched(TenantProfile profile)
|
||||||
|
{
|
||||||
|
// Derived classes override to reset their state
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user