diff --git a/SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs b/SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs index 4cb519b..64bcc36 100644 --- a/SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs +++ b/SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs @@ -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.Instance) { } + public Func, Task>? OperationFunc { get; set; } + protected override Task RunOperationAsync(CancellationToken ct, IProgress progress) + => OperationFunc?.Invoke(ct, progress) ?? Task.CompletedTask; + } + + [Fact] + public async Task IsRunning_IsTrueWhileOperationExecutes_ThenFalseAfterCompletion() + { + var vm = new TestViewModel(); + var tcs = new TaskCompletionSource(); + 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(); + + 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(); + + 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)); + } } diff --git a/SharepointToolbox/Core/Messages/ProgressUpdatedMessage.cs b/SharepointToolbox/Core/Messages/ProgressUpdatedMessage.cs new file mode 100644 index 0000000..4af4e13 --- /dev/null +++ b/SharepointToolbox/Core/Messages/ProgressUpdatedMessage.cs @@ -0,0 +1,9 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Core.Messages; + +public sealed class ProgressUpdatedMessage : ValueChangedMessage +{ + public ProgressUpdatedMessage(OperationProgress progress) : base(progress) { } +} diff --git a/SharepointToolbox/ViewModels/FeatureViewModelBase.cs b/SharepointToolbox/ViewModels/FeatureViewModelBase.cs new file mode 100644 index 0000000..61ef242 --- /dev/null +++ b/SharepointToolbox/ViewModels/FeatureViewModelBase.cs @@ -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 _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 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(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 progress); + + protected override void OnActivated() + { + Messenger.Register(this, (r, m) => ((FeatureViewModelBase)r).OnTenantSwitched(m.Value)); + } + + protected virtual void OnTenantSwitched(TenantProfile profile) + { + // Derived classes override to reset their state + } +}