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
+}