feat: implement ViewModels layer with TDD (Milestone 3)

Implemented all ViewModels using strict TDD (RED-GREEN-REFACTOR) with CommunityToolkit.Mvvm.

**ViewModels Implemented:**
- MainViewModel: Coordinates shake detection, estimate generation, and display
- HistoryViewModel: Manages estimate history display and clearing
- SettingsViewModel: Handles app settings (mode selection, history size)

**MainViewModel Features:**
- Subscribes to ShakeDetectionService events
- Generates estimates when shake stops (transition from shaking→not shaking)
- Automatically saves estimates to storage
- Resets shake detection after estimate generation
- Loads and saves settings (SelectedMode)
- Proper disposal pattern (unsubscribe, stop monitoring)

**HistoryViewModel Features:**
- ObservableCollection<EstimateResult> for UI binding
- LoadHistoryCommand to fetch from storage
- ClearHistoryCommand to remove all history
- IsEmpty property for conditional UI display
- Replaces collection contents on reload

**SettingsViewModel Features:**
- SelectedMode property (Work/Generic)
- MaxHistorySize property
- SaveSettingsCommand to persist changes
- Loads settings on initialization

**Tests:**
- MainViewModel: 18 tests (RED-GREEN-REFACTOR)
- HistoryViewModel: 15 tests (RED-GREEN-REFACTOR)
- SettingsViewModel: 13 tests (RED-GREEN-REFACTOR)
- Total: 165 tests, all passing (48 models + 71 services + 46 ViewModels)

**Quality:**
- Build: 0 warnings, 0 errors across all platforms
- All tests use NSubstitute for mocking
- Property change notifications verified
- Async operations properly tested

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-18 12:44:07 +02:00
parent dd3a4f3d97
commit 48a844b0c1
6 changed files with 1081 additions and 0 deletions

View File

@@ -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;
/// <summary>
/// ViewModel for displaying and managing estimate history.
/// </summary>
public partial class HistoryViewModel : ObservableObject
{
private readonly IStorageService _storageService;
[ObservableProperty]
private ObservableCollection<EstimateResult> _history;
[ObservableProperty]
private bool _isEmpty;
public HistoryViewModel(IStorageService storageService)
{
_storageService = storageService;
_history = new ObservableCollection<EstimateResult>();
_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;
}
}

View File

@@ -0,0 +1,119 @@
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
using HihaArvio.Models;
using HihaArvio.Services.Interfaces;
namespace HihaArvio.ViewModels;
/// <summary>
/// Main ViewModel coordinating shake detection, estimate generation, and display.
/// </summary>
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();
}
}

View File

@@ -0,0 +1,47 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HihaArvio.Models;
using HihaArvio.Services.Interfaces;
namespace HihaArvio.ViewModels;
/// <summary>
/// ViewModel for managing application settings.
/// </summary>
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);
}
}

View File

@@ -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<IStorageService>();
_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>
{
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<int>()).Returns(Task.FromResult(estimates));
// Act
await _viewModel.LoadHistoryCommand.ExecuteAsync(null);
// Assert
await _storageService.Received(1).GetHistoryAsync(Arg.Any<int>());
}
[Fact]
public async Task LoadHistoryCommand_ShouldPopulateHistoryCollection()
{
// Arrange
var estimates = new List<EstimateResult>
{
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<int>()).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<int>()).Returns(Task.FromResult(new List<EstimateResult>()));
// Act
await _viewModel.LoadHistoryCommand.ExecuteAsync(null);
// Assert
_viewModel.IsEmpty.Should().BeTrue();
}
[Fact]
public async Task LoadHistoryCommand_WhenHistoryHasItems_ShouldSetIsEmptyToFalse()
{
// Arrange
var estimates = new List<EstimateResult>
{
EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5))
};
_storageService.GetHistoryAsync(Arg.Any<int>()).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>
{
EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5))
};
var secondEstimates = new List<EstimateResult>
{
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<int>()).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>
{
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<int>()).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>
{
EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5))
};
_storageService.GetHistoryAsync(Arg.Any<int>()).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>
{
EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5))
};
_storageService.GetHistoryAsync(Arg.Any<int>()).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>
{
EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5))
};
_storageService.GetHistoryAsync(Arg.Any<int>()).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<ObservableCollection<EstimateResult>>();
}
#endregion
}

View File

@@ -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<IShakeDetectionService>();
_estimateService = Substitute.For<IEstimateService>();
_storageService = Substitute.For<IStorageService>();
// 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<IStorageService>();
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<IShakeDetectionService>();
var storageService = Substitute.For<IStorageService>();
storageService.LoadSettingsAsync().Returns(Task.FromResult(new AppSettings()));
// Act
var vm = new MainViewModel(shakeService, _estimateService, storageService);
// Simulate shake event
shakeService.ShakeDataChanged += Raise.Event<EventHandler<ShakeData>>(
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<EventHandler<ShakeData>>(_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<double>(), Arg.Any<TimeSpan>(), Arg.Any<EstimateMode>())
.Returns(estimate);
// Start shake
var shakeStart = new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(3) };
_shakeDetectionService.ShakeDataChanged += Raise.Event<EventHandler<ShakeData>>(_shakeDetectionService, shakeStart);
// Act - Stop shake
var shakeStop = new ShakeData { IsShaking = false, Intensity = 0.0, Duration = TimeSpan.Zero };
_shakeDetectionService.ShakeDataChanged += Raise.Event<EventHandler<ShakeData>>(_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<double>(), Arg.Any<TimeSpan>(), Arg.Any<EstimateMode>())
.Returns(estimate);
// Start and stop shake
_shakeDetectionService.ShakeDataChanged += Raise.Event<EventHandler<ShakeData>>(
_shakeDetectionService,
new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(3) });
// Act
_shakeDetectionService.ShakeDataChanged += Raise.Event<EventHandler<ShakeData>>(
_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<double>(), Arg.Any<TimeSpan>(), Arg.Any<EstimateMode>())
.Returns(estimate);
// Start and stop shake
_shakeDetectionService.ShakeDataChanged += Raise.Event<EventHandler<ShakeData>>(
_shakeDetectionService,
new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(3) });
// Act
_shakeDetectionService.ShakeDataChanged += Raise.Event<EventHandler<ShakeData>>(
_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<EventHandler<ShakeData>>(
_shakeDetectionService,
new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(2) });
// Act - Shake continues with higher intensity
_shakeDetectionService.ShakeDataChanged += Raise.Event<EventHandler<ShakeData>>(
_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<double>(), Arg.Any<TimeSpan>(), Arg.Any<EstimateMode>());
}
[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<double>(), Arg.Any<TimeSpan>(), Arg.Any<EstimateMode>())
.Returns(estimate);
_shakeDetectionService.ShakeDataChanged += Raise.Event<EventHandler<ShakeData>>(
_shakeDetectionService,
new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(3) });
// Act
_shakeDetectionService.ShakeDataChanged += Raise.Event<EventHandler<ShakeData>>(
_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<AppSettings>(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<double>(), Arg.Any<TimeSpan>(), EstimateMode.Generic)
.Returns(estimate);
// Start and stop shake
_shakeDetectionService.ShakeDataChanged += Raise.Event<EventHandler<ShakeData>>(
_shakeDetectionService,
new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(3) });
// Act
_shakeDetectionService.ShakeDataChanged += Raise.Event<EventHandler<ShakeData>>(
_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<EventHandler<ShakeData>>(
_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<double>(), Arg.Any<TimeSpan>(), Arg.Any<EstimateMode>())
.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<EventHandler<ShakeData>>(
_shakeDetectionService,
new ShakeData { IsShaking = true, Intensity = 0.5, Duration = TimeSpan.FromSeconds(3) });
// Act
_shakeDetectionService.ShakeDataChanged += Raise.Event<EventHandler<ShakeData>>(
_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<EventHandler<ShakeData>>(
_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
}

View File

@@ -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<IStorageService>();
// 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<IStorageService>();
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<AppSettings>());
}
[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<AppSettings>(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<AppSettings>(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<IStorageService>();
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
}