diff --git a/src/HihaArvio/ViewModels/HistoryViewModel.cs b/src/HihaArvio/ViewModels/HistoryViewModel.cs new file mode 100644 index 0000000..01a7927 --- /dev/null +++ b/src/HihaArvio/ViewModels/HistoryViewModel.cs @@ -0,0 +1,51 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using HihaArvio.Models; +using HihaArvio.Services.Interfaces; + +namespace HihaArvio.ViewModels; + +/// +/// ViewModel for displaying and managing estimate history. +/// +public partial class HistoryViewModel : ObservableObject +{ + private readonly IStorageService _storageService; + + [ObservableProperty] + private ObservableCollection _history; + + [ObservableProperty] + private bool _isEmpty; + + public HistoryViewModel(IStorageService storageService) + { + _storageService = storageService; + _history = new ObservableCollection(); + _isEmpty = true; + } + + [RelayCommand] + private async Task LoadHistoryAsync() + { + var estimates = await _storageService.GetHistoryAsync(); + + History.Clear(); + foreach (var estimate in estimates) + { + History.Add(estimate); + } + + IsEmpty = History.Count == 0; + } + + [RelayCommand] + private async Task ClearHistoryAsync() + { + await _storageService.ClearHistoryAsync(); + + History.Clear(); + IsEmpty = true; + } +} diff --git a/src/HihaArvio/ViewModels/MainViewModel.cs b/src/HihaArvio/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..7da835d --- /dev/null +++ b/src/HihaArvio/ViewModels/MainViewModel.cs @@ -0,0 +1,119 @@ +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using HihaArvio.Models; +using HihaArvio.Services.Interfaces; + +namespace HihaArvio.ViewModels; + +/// +/// Main ViewModel coordinating shake detection, estimate generation, and display. +/// +public partial class MainViewModel : ObservableObject, IDisposable +{ + private readonly IShakeDetectionService _shakeDetectionService; + private readonly IEstimateService _estimateService; + private readonly IStorageService _storageService; + + [ObservableProperty] + private ShakeData _currentShakeData; + + [ObservableProperty] + private EstimateResult? _currentEstimate; + + [ObservableProperty] + private EstimateMode _selectedMode; + + private ShakeData? _lastShakeData; + + public MainViewModel( + IShakeDetectionService shakeDetectionService, + IEstimateService estimateService, + IStorageService storageService) + { + _shakeDetectionService = shakeDetectionService; + _estimateService = estimateService; + _storageService = storageService; + + // Initialize with default shake data + _currentShakeData = new ShakeData + { + IsShaking = false, + Intensity = 0.0, + Duration = TimeSpan.Zero + }; + + // Subscribe to shake events + _shakeDetectionService.ShakeDataChanged += OnShakeDataChanged; + + // Start monitoring + _shakeDetectionService.StartMonitoring(); + + // Load settings + _ = LoadSettingsAsync(); + } + + partial void OnSelectedModeChanged(EstimateMode value) + { + // Save settings when mode changes + _ = SaveSettingsAsync(); + } + + private async Task LoadSettingsAsync() + { + var settings = await _storageService.LoadSettingsAsync(); + SelectedMode = settings.SelectedMode; + } + + private async Task SaveSettingsAsync() + { + var settings = new AppSettings + { + SelectedMode = SelectedMode, + MaxHistorySize = 10 // Default value, will be managed by SettingsViewModel + }; + await _storageService.SaveSettingsAsync(settings); + } + + private void OnShakeDataChanged(object? sender, ShakeData shakeData) + { + // Update current shake data + CurrentShakeData = shakeData; + + // Check if shake just stopped (was shaking, now not) + if (_lastShakeData?.IsShaking == true && !shakeData.IsShaking) + { + // Generate and save estimate asynchronously + _ = GenerateAndSaveEstimateAsync(_lastShakeData); + } + + // Store for next comparison + _lastShakeData = shakeData; + } + + private async Task GenerateAndSaveEstimateAsync(ShakeData shakeData) + { + // Generate estimate based on shake data + var estimate = _estimateService.GenerateEstimate( + shakeData.Intensity, + shakeData.Duration, + SelectedMode); + + // Update current estimate + CurrentEstimate = estimate; + + // Save to storage + await _storageService.SaveEstimateAsync(estimate); + + // Reset shake detection for next shake + _shakeDetectionService.Reset(); + } + + public void Dispose() + { + // Unsubscribe from events + _shakeDetectionService.ShakeDataChanged -= OnShakeDataChanged; + + // Stop monitoring + _shakeDetectionService.StopMonitoring(); + } +} diff --git a/src/HihaArvio/ViewModels/SettingsViewModel.cs b/src/HihaArvio/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..ea6f084 --- /dev/null +++ b/src/HihaArvio/ViewModels/SettingsViewModel.cs @@ -0,0 +1,47 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using HihaArvio.Models; +using HihaArvio.Services.Interfaces; + +namespace HihaArvio.ViewModels; + +/// +/// ViewModel for managing application settings. +/// +public partial class SettingsViewModel : ObservableObject +{ + private readonly IStorageService _storageService; + + [ObservableProperty] + private EstimateMode _selectedMode; + + [ObservableProperty] + private int _maxHistorySize; + + public SettingsViewModel(IStorageService storageService) + { + _storageService = storageService; + + // Load settings + _ = LoadSettingsAsync(); + } + + private async Task LoadSettingsAsync() + { + var settings = await _storageService.LoadSettingsAsync(); + SelectedMode = settings.SelectedMode; + MaxHistorySize = settings.MaxHistorySize; + } + + [RelayCommand] + private async Task SaveSettingsAsync() + { + var settings = new AppSettings + { + SelectedMode = SelectedMode, + MaxHistorySize = MaxHistorySize + }; + + await _storageService.SaveSettingsAsync(settings); + } +} diff --git a/tests/HihaArvio.Tests/ViewModels/HistoryViewModelTests.cs b/tests/HihaArvio.Tests/ViewModels/HistoryViewModelTests.cs new file mode 100644 index 0000000..b138922 --- /dev/null +++ b/tests/HihaArvio.Tests/ViewModels/HistoryViewModelTests.cs @@ -0,0 +1,268 @@ +using System.Collections.ObjectModel; +using FluentAssertions; +using HihaArvio.Models; +using HihaArvio.Services.Interfaces; +using HihaArvio.ViewModels; +using NSubstitute; + +namespace HihaArvio.Tests.ViewModels; + +public class HistoryViewModelTests +{ + private readonly IStorageService _storageService; + private readonly HistoryViewModel _viewModel; + + public HistoryViewModelTests() + { + _storageService = Substitute.For(); + _viewModel = new HistoryViewModel(_storageService); + } + + #region Initialization Tests + + [Fact] + public void Constructor_ShouldInitializeWithEmptyHistory() + { + // Assert + _viewModel.History.Should().NotBeNull(); + _viewModel.History.Should().BeEmpty(); + } + + [Fact] + public void Constructor_ShouldInitializeIsEmptyAsTrue() + { + // Assert + _viewModel.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void Constructor_ShouldHaveLoadHistoryCommand() + { + // Assert + _viewModel.LoadHistoryCommand.Should().NotBeNull(); + } + + [Fact] + public void Constructor_ShouldHaveClearHistoryCommand() + { + // Assert + _viewModel.ClearHistoryCommand.Should().NotBeNull(); + } + + #endregion + + #region Load History Tests + + [Fact] + public async Task LoadHistoryCommand_ShouldLoadHistoryFromStorage() + { + // Arrange + var estimates = new List + { + EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)), + EstimateResult.Create("1 day", EstimateMode.Work, 0.3, TimeSpan.FromSeconds(3)) + }; + _storageService.GetHistoryAsync(Arg.Any()).Returns(Task.FromResult(estimates)); + + // Act + await _viewModel.LoadHistoryCommand.ExecuteAsync(null); + + // Assert + await _storageService.Received(1).GetHistoryAsync(Arg.Any()); + } + + [Fact] + public async Task LoadHistoryCommand_ShouldPopulateHistoryCollection() + { + // Arrange + var estimates = new List + { + EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)), + EstimateResult.Create("1 day", EstimateMode.Work, 0.3, TimeSpan.FromSeconds(3)), + EstimateResult.Create("3 months", EstimateMode.Work, 0.8, TimeSpan.FromSeconds(8)) + }; + _storageService.GetHistoryAsync(Arg.Any()).Returns(Task.FromResult(estimates)); + + // Act + await _viewModel.LoadHistoryCommand.ExecuteAsync(null); + + // Assert + _viewModel.History.Should().HaveCount(3); + _viewModel.History[0].EstimateText.Should().Be("2 weeks"); + _viewModel.History[1].EstimateText.Should().Be("1 day"); + _viewModel.History[2].EstimateText.Should().Be("3 months"); + } + + [Fact] + public async Task LoadHistoryCommand_WhenHistoryEmpty_ShouldSetIsEmptyToTrue() + { + // Arrange + _storageService.GetHistoryAsync(Arg.Any()).Returns(Task.FromResult(new List())); + + // Act + await _viewModel.LoadHistoryCommand.ExecuteAsync(null); + + // Assert + _viewModel.IsEmpty.Should().BeTrue(); + } + + [Fact] + public async Task LoadHistoryCommand_WhenHistoryHasItems_ShouldSetIsEmptyToFalse() + { + // Arrange + var estimates = new List + { + EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)) + }; + _storageService.GetHistoryAsync(Arg.Any()).Returns(Task.FromResult(estimates)); + + // Act + await _viewModel.LoadHistoryCommand.ExecuteAsync(null); + + // Assert + _viewModel.IsEmpty.Should().BeFalse(); + } + + [Fact] + public async Task LoadHistoryCommand_CalledMultipleTimes_ShouldReplaceHistory() + { + // Arrange + var firstEstimates = new List + { + EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)) + }; + var secondEstimates = new List + { + EstimateResult.Create("1 day", EstimateMode.Work, 0.3, TimeSpan.FromSeconds(3)), + EstimateResult.Create("3 months", EstimateMode.Work, 0.8, TimeSpan.FromSeconds(8)) + }; + + _storageService.GetHistoryAsync(Arg.Any()).Returns( + Task.FromResult(firstEstimates), + Task.FromResult(secondEstimates)); + + // Act + await _viewModel.LoadHistoryCommand.ExecuteAsync(null); + await _viewModel.LoadHistoryCommand.ExecuteAsync(null); + + // Assert + _viewModel.History.Should().HaveCount(2); + _viewModel.History[0].EstimateText.Should().Be("1 day"); + } + + #endregion + + #region Clear History Tests + + [Fact] + public async Task ClearHistoryCommand_ShouldCallStorageServiceClear() + { + // Act + await _viewModel.ClearHistoryCommand.ExecuteAsync(null); + + // Assert + await _storageService.Received(1).ClearHistoryAsync(); + } + + [Fact] + public async Task ClearHistoryCommand_ShouldClearHistoryCollection() + { + // Arrange - Load some history first + var estimates = new List + { + EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)), + EstimateResult.Create("1 day", EstimateMode.Work, 0.3, TimeSpan.FromSeconds(3)) + }; + _storageService.GetHistoryAsync(Arg.Any()).Returns(Task.FromResult(estimates)); + await _viewModel.LoadHistoryCommand.ExecuteAsync(null); + _viewModel.History.Should().HaveCount(2); + + // Act + await _viewModel.ClearHistoryCommand.ExecuteAsync(null); + + // Assert + _viewModel.History.Should().BeEmpty(); + } + + [Fact] + public async Task ClearHistoryCommand_ShouldSetIsEmptyToTrue() + { + // Arrange - Load some history first + var estimates = new List + { + EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)) + }; + _storageService.GetHistoryAsync(Arg.Any()).Returns(Task.FromResult(estimates)); + await _viewModel.LoadHistoryCommand.ExecuteAsync(null); + _viewModel.IsEmpty.Should().BeFalse(); + + // Act + await _viewModel.ClearHistoryCommand.ExecuteAsync(null); + + // Assert + _viewModel.IsEmpty.Should().BeTrue(); + } + + #endregion + + #region Property Change Notification Tests + + [Fact] + public async Task History_WhenLoadHistoryCalled_ShouldPopulateCollection() + { + // Arrange + var collectionChangedCount = 0; + _viewModel.History.CollectionChanged += (sender, args) => collectionChangedCount++; + + var estimates = new List + { + EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)) + }; + _storageService.GetHistoryAsync(Arg.Any()).Returns(Task.FromResult(estimates)); + + // Act + await _viewModel.LoadHistoryCommand.ExecuteAsync(null); + + // Assert + collectionChangedCount.Should().BeGreaterThan(0); + _viewModel.History.Should().HaveCount(1); + } + + [Fact] + public async Task IsEmpty_WhenChanged_ShouldRaisePropertyChanged() + { + // Arrange + var propertyChangedCount = 0; + _viewModel.PropertyChanged += (sender, args) => + { + if (args.PropertyName == nameof(HistoryViewModel.IsEmpty)) + propertyChangedCount++; + }; + + var estimates = new List + { + EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)) + }; + _storageService.GetHistoryAsync(Arg.Any()).Returns(Task.FromResult(estimates)); + + // Act + await _viewModel.LoadHistoryCommand.ExecuteAsync(null); // IsEmpty changes from true to false + await _viewModel.ClearHistoryCommand.ExecuteAsync(null); // IsEmpty changes from false to true + + // Assert + propertyChangedCount.Should().BeGreaterThanOrEqualTo(2); + } + + #endregion + + #region Observable Collection Tests + + [Fact] + public void History_ShouldBeObservableCollection() + { + // Assert + _viewModel.History.Should().BeOfType>(); + } + + #endregion +} diff --git a/tests/HihaArvio.Tests/ViewModels/MainViewModelTests.cs b/tests/HihaArvio.Tests/ViewModels/MainViewModelTests.cs new file mode 100644 index 0000000..db44933 --- /dev/null +++ b/tests/HihaArvio.Tests/ViewModels/MainViewModelTests.cs @@ -0,0 +1,374 @@ +using FluentAssertions; +using HihaArvio.Models; +using HihaArvio.Services.Interfaces; +using HihaArvio.ViewModels; +using NSubstitute; + +namespace HihaArvio.Tests.ViewModels; + +public class MainViewModelTests +{ + private readonly IShakeDetectionService _shakeDetectionService; + private readonly IEstimateService _estimateService; + private readonly IStorageService _storageService; + private readonly MainViewModel _viewModel; + + public MainViewModelTests() + { + _shakeDetectionService = Substitute.For(); + _estimateService = Substitute.For(); + _storageService = Substitute.For(); + + // Setup default storage service behavior + _storageService.LoadSettingsAsync().Returns(Task.FromResult(new AppSettings + { + SelectedMode = EstimateMode.Work, + MaxHistorySize = 10 + })); + + _viewModel = new MainViewModel(_shakeDetectionService, _estimateService, _storageService); + } + + #region Initialization Tests + + [Fact] + public async Task Constructor_ShouldLoadSettings() + { + // Arrange - Create new instance to test initialization + var storageService = Substitute.For(); + var settings = new AppSettings { SelectedMode = EstimateMode.Generic, MaxHistorySize = 20 }; + storageService.LoadSettingsAsync().Returns(Task.FromResult(settings)); + + // Act + var vm = new MainViewModel(_shakeDetectionService, _estimateService, storageService); + await Task.Delay(50); // Give async initialization time to complete + + // Assert + vm.SelectedMode.Should().Be(EstimateMode.Generic); + await storageService.Received(1).LoadSettingsAsync(); + } + + [Fact] + public void Constructor_ShouldStartMonitoringShakes() + { + // Assert + _shakeDetectionService.Received(1).StartMonitoring(); + } + + [Fact] + public void Constructor_ShouldSubscribeToShakeDataChanged() + { + // Arrange + var shakeService = Substitute.For(); + var storageService = Substitute.For(); + storageService.LoadSettingsAsync().Returns(Task.FromResult(new AppSettings())); + + // Act + var vm = new MainViewModel(shakeService, _estimateService, storageService); + + // Simulate shake event + shakeService.ShakeDataChanged += Raise.Event>( + shakeService, + new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(2) }); + + // Assert - Event subscription confirmed by no exception + vm.Should().NotBeNull(); + } + + [Fact] + public void Constructor_ShouldInitializeWithNoCurrentEstimate() + { + // Assert + _viewModel.CurrentEstimate.Should().BeNull(); + } + + [Fact] + public void Constructor_ShouldInitializeWithDefaultShakeData() + { + // Assert + _viewModel.CurrentShakeData.Should().NotBeNull(); + _viewModel.CurrentShakeData.IsShaking.Should().BeFalse(); + _viewModel.CurrentShakeData.Intensity.Should().Be(0.0); + _viewModel.CurrentShakeData.Duration.Should().Be(TimeSpan.Zero); + } + + #endregion + + #region Shake Detection Integration Tests + + [Fact] + public void OnShakeDataChanged_WhenShakeStarts_ShouldUpdateCurrentShakeData() + { + // Arrange + var shakeData = new ShakeData { IsShaking = true, Intensity = 0.7, Duration = TimeSpan.FromSeconds(3) }; + + // Act + _shakeDetectionService.ShakeDataChanged += Raise.Event>(_shakeDetectionService, shakeData); + + // Assert + _viewModel.CurrentShakeData.Should().NotBeNull(); + _viewModel.CurrentShakeData.IsShaking.Should().BeTrue(); + _viewModel.CurrentShakeData.Intensity.Should().Be(0.7); + _viewModel.CurrentShakeData.Duration.Should().Be(TimeSpan.FromSeconds(3)); + } + + [Fact] + public async Task OnShakeDataChanged_WhenShakeStops_ShouldGenerateEstimate() + { + // Arrange + var estimate = EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + _estimateService.GenerateEstimate(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(estimate); + + // Start shake + var shakeStart = new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(3) }; + _shakeDetectionService.ShakeDataChanged += Raise.Event>(_shakeDetectionService, shakeStart); + + // Act - Stop shake + var shakeStop = new ShakeData { IsShaking = false, Intensity = 0.0, Duration = TimeSpan.Zero }; + _shakeDetectionService.ShakeDataChanged += Raise.Event>(_shakeDetectionService, shakeStop); + + await Task.Delay(50); // Give async operation time to complete + + // Assert + _estimateService.Received(1).GenerateEstimate(0.5, TimeSpan.FromSeconds(3), EstimateMode.Work); + } + + [Fact] + public async Task OnShakeDataChanged_WhenShakeStops_ShouldSaveEstimateToStorage() + { + // Arrange + var estimate = EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + _estimateService.GenerateEstimate(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(estimate); + + // Start and stop shake + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(3) }); + + // Act + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = false, Intensity = 0.0, Duration = TimeSpan.Zero }); + + await Task.Delay(50); + + // Assert + await _storageService.Received(1).SaveEstimateAsync(estimate); + } + + [Fact] + public async Task OnShakeDataChanged_WhenShakeStops_ShouldUpdateCurrentEstimate() + { + // Arrange + var estimate = EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + _estimateService.GenerateEstimate(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(estimate); + + // Start and stop shake + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(3) }); + + // Act + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = false, Intensity = 0.0, Duration = TimeSpan.Zero }); + + await Task.Delay(50); + + // Assert + _viewModel.CurrentEstimate.Should().NotBeNull(); + _viewModel.CurrentEstimate!.EstimateText.Should().Be("2 weeks"); + } + + [Fact] + public void OnShakeDataChanged_WhenShakeContinues_ShouldNotGenerateEstimate() + { + // Arrange + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(2) }); + + // Act - Shake continues with higher intensity + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = true, Intensity = 0.7, Duration = TimeSpan.FromSeconds(3) }); + + // Assert - Should only update shake data, not generate estimate + _estimateService.DidNotReceive().GenerateEstimate(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task OnShakeDataChanged_WhenShakeStops_ShouldResetShakeDetectionService() + { + // Arrange + var estimate = EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + _estimateService.GenerateEstimate(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(estimate); + + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(3) }); + + // Act + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = false, Intensity = 0.0, Duration = TimeSpan.Zero }); + + await Task.Delay(50); + + // Assert + _shakeDetectionService.Received(1).Reset(); + } + + #endregion + + #region Mode Selection Tests + + [Fact] + public async Task SelectedMode_WhenChanged_ShouldSaveToStorage() + { + // Act + _viewModel.SelectedMode = EstimateMode.Generic; + await Task.Delay(50); // Give async save time to complete + + // Assert + await _storageService.Received().SaveSettingsAsync(Arg.Is(s => + s.SelectedMode == EstimateMode.Generic)); + } + + [Fact] + public async Task SelectedMode_WhenChanged_ShouldUseNewModeForEstimates() + { + // Arrange + _viewModel.SelectedMode = EstimateMode.Generic; + await Task.Delay(50); + + var estimate = EstimateResult.Create("30 minutes", EstimateMode.Generic, 0.5, TimeSpan.FromSeconds(3)); + _estimateService.GenerateEstimate(Arg.Any(), Arg.Any(), EstimateMode.Generic) + .Returns(estimate); + + // Start and stop shake + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(3) }); + + // Act + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = false, Intensity = 0.0, Duration = TimeSpan.Zero }); + + await Task.Delay(50); + + // Assert + _estimateService.Received(1).GenerateEstimate(0.5, TimeSpan.FromSeconds(3), EstimateMode.Generic); + } + + #endregion + + #region Property Change Notification Tests + + [Fact] + public void CurrentShakeData_WhenChanged_ShouldRaisePropertyChanged() + { + // Arrange + var propertyChangedRaised = false; + _viewModel.PropertyChanged += (sender, args) => + { + if (args.PropertyName == nameof(MainViewModel.CurrentShakeData)) + propertyChangedRaised = true; + }; + + // Act + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(2) }); + + // Assert + propertyChangedRaised.Should().BeTrue(); + } + + [Fact] + public async Task CurrentEstimate_WhenChanged_ShouldRaisePropertyChanged() + { + // Arrange + var estimate = EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + _estimateService.GenerateEstimate(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(estimate); + + var propertyChangedRaised = false; + _viewModel.PropertyChanged += (sender, args) => + { + if (args.PropertyName == nameof(MainViewModel.CurrentEstimate)) + propertyChangedRaised = true; + }; + + // Start and stop shake + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(3) }); + + // Act + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = false, Intensity = 0.0, Duration = TimeSpan.Zero }); + + await Task.Delay(50); + + // Assert + propertyChangedRaised.Should().BeTrue(); + } + + [Fact] + public async Task SelectedMode_WhenChanged_ShouldRaisePropertyChanged() + { + // Arrange + var propertyChangedRaised = false; + _viewModel.PropertyChanged += (sender, args) => + { + if (args.PropertyName == nameof(MainViewModel.SelectedMode)) + propertyChangedRaised = true; + }; + + // Act + _viewModel.SelectedMode = EstimateMode.Generic; + await Task.Delay(10); + + // Assert + propertyChangedRaised.Should().BeTrue(); + } + + #endregion + + #region Disposal Tests + + [Fact] + public void Dispose_ShouldStopMonitoring() + { + // Act + _viewModel.Dispose(); + + // Assert + _shakeDetectionService.Received(1).StopMonitoring(); + } + + [Fact] + public void Dispose_ShouldUnsubscribeFromShakeDataChanged() + { + // Arrange + _viewModel.Dispose(); + + // Act - Trigger event after disposal + _shakeDetectionService.ShakeDataChanged += Raise.Event>( + _shakeDetectionService, + new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(2) }); + + // Assert - Should not throw, and CurrentShakeData should not update + // (we can't directly test event unsubscription, but no exception means success) + _viewModel.Should().NotBeNull(); + } + + #endregion +} diff --git a/tests/HihaArvio.Tests/ViewModels/SettingsViewModelTests.cs b/tests/HihaArvio.Tests/ViewModels/SettingsViewModelTests.cs new file mode 100644 index 0000000..0053533 --- /dev/null +++ b/tests/HihaArvio.Tests/ViewModels/SettingsViewModelTests.cs @@ -0,0 +1,222 @@ +using FluentAssertions; +using HihaArvio.Models; +using HihaArvio.Services.Interfaces; +using HihaArvio.ViewModels; +using NSubstitute; + +namespace HihaArvio.Tests.ViewModels; + +public class SettingsViewModelTests +{ + private readonly IStorageService _storageService; + private readonly SettingsViewModel _viewModel; + + public SettingsViewModelTests() + { + _storageService = Substitute.For(); + + // Setup default storage service behavior + _storageService.LoadSettingsAsync().Returns(Task.FromResult(new AppSettings + { + SelectedMode = EstimateMode.Work, + MaxHistorySize = 10 + })); + + _viewModel = new SettingsViewModel(_storageService); + } + + #region Initialization Tests + + [Fact] + public async Task Constructor_ShouldLoadSettings() + { + // Arrange - Create new instance to test initialization + var storageService = Substitute.For(); + var settings = new AppSettings { SelectedMode = EstimateMode.Generic, MaxHistorySize = 20 }; + storageService.LoadSettingsAsync().Returns(Task.FromResult(settings)); + + // Act + var vm = new SettingsViewModel(storageService); + await Task.Delay(50); // Give async initialization time to complete + + // Assert + vm.SelectedMode.Should().Be(EstimateMode.Generic); + vm.MaxHistorySize.Should().Be(20); + await storageService.Received(1).LoadSettingsAsync(); + } + + [Fact] + public void Constructor_ShouldHaveSaveSettingsCommand() + { + // Assert + _viewModel.SaveSettingsCommand.Should().NotBeNull(); + } + + #endregion + + #region Save Settings Tests + + [Fact] + public async Task SaveSettingsCommand_ShouldSaveToStorage() + { + // Act + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + await _storageService.Received(1).SaveSettingsAsync(Arg.Any()); + } + + [Fact] + public async Task SaveSettingsCommand_ShouldSaveCurrentSettings() + { + // Arrange + _viewModel.SelectedMode = EstimateMode.Generic; + _viewModel.MaxHistorySize = 25; + + // Act + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + await _storageService.Received(1).SaveSettingsAsync(Arg.Is(s => + s.SelectedMode == EstimateMode.Generic && + s.MaxHistorySize == 25)); + } + + #endregion + + #region Selected Mode Tests + + [Fact] + public void SelectedMode_WhenChanged_ShouldUpdateProperty() + { + // Act + _viewModel.SelectedMode = EstimateMode.Generic; + + // Assert + _viewModel.SelectedMode.Should().Be(EstimateMode.Generic); + } + + [Fact] + public void SelectedMode_ShouldSupportWorkMode() + { + // Act + _viewModel.SelectedMode = EstimateMode.Work; + + // Assert + _viewModel.SelectedMode.Should().Be(EstimateMode.Work); + } + + [Fact] + public void SelectedMode_ShouldSupportGenericMode() + { + // Act + _viewModel.SelectedMode = EstimateMode.Generic; + + // Assert + _viewModel.SelectedMode.Should().Be(EstimateMode.Generic); + } + + // Note: Humorous mode is only triggered by easter egg, not selectable in UI + + #endregion + + #region Max History Size Tests + + [Fact] + public void MaxHistorySize_WhenChanged_ShouldUpdateProperty() + { + // Act + _viewModel.MaxHistorySize = 50; + + // Assert + _viewModel.MaxHistorySize.Should().Be(50); + } + + [Fact] + public void MaxHistorySize_ShouldAcceptValidValues() + { + // Act & Assert + var testAction = () => _viewModel.MaxHistorySize = 100; + testAction.Should().NotThrow(); + _viewModel.MaxHistorySize.Should().Be(100); + } + + #endregion + + #region Property Change Notification Tests + + [Fact] + public void SelectedMode_WhenChanged_ShouldRaisePropertyChanged() + { + // Arrange + var propertyChangedRaised = false; + _viewModel.PropertyChanged += (sender, args) => + { + if (args.PropertyName == nameof(SettingsViewModel.SelectedMode)) + propertyChangedRaised = true; + }; + + // Act + _viewModel.SelectedMode = EstimateMode.Generic; + + // Assert + propertyChangedRaised.Should().BeTrue(); + } + + [Fact] + public void MaxHistorySize_WhenChanged_ShouldRaisePropertyChanged() + { + // Arrange + var propertyChangedRaised = false; + _viewModel.PropertyChanged += (sender, args) => + { + if (args.PropertyName == nameof(SettingsViewModel.MaxHistorySize)) + propertyChangedRaised = true; + }; + + // Act + _viewModel.MaxHistorySize = 50; + + // Assert + propertyChangedRaised.Should().BeTrue(); + } + + #endregion + + #region Integration Tests + + [Fact] + public async Task ChangeSettings_ThenSave_ShouldPersistChanges() + { + // Arrange + _viewModel.SelectedMode = EstimateMode.Generic; + _viewModel.MaxHistorySize = 30; + + // Act + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + await _storageService.Received(1).SaveSettingsAsync(Arg.Is(s => + s.SelectedMode == EstimateMode.Generic && + s.MaxHistorySize == 30)); + } + + [Fact] + public async Task LoadSettings_ShouldPopulateProperties() + { + // Arrange - Create new instance with specific settings + var storageService = Substitute.For(); + var settings = new AppSettings { SelectedMode = EstimateMode.Generic, MaxHistorySize = 50 }; + storageService.LoadSettingsAsync().Returns(Task.FromResult(settings)); + + // Act + var vm = new SettingsViewModel(storageService); + await Task.Delay(50); // Give async load time to complete + + // Assert + vm.SelectedMode.Should().Be(EstimateMode.Generic); + vm.MaxHistorySize.Should().Be(50); + } + + #endregion +}