mirror of
https://github.com/ivuorinen/hiha-arvio.git
synced 2026-01-26 03:14:00 +00:00
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:
51
src/HihaArvio/ViewModels/HistoryViewModel.cs
Normal file
51
src/HihaArvio/ViewModels/HistoryViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
119
src/HihaArvio/ViewModels/MainViewModel.cs
Normal file
119
src/HihaArvio/ViewModels/MainViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
47
src/HihaArvio/ViewModels/SettingsViewModel.cs
Normal file
47
src/HihaArvio/ViewModels/SettingsViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
268
tests/HihaArvio.Tests/ViewModels/HistoryViewModelTests.cs
Normal file
268
tests/HihaArvio.Tests/ViewModels/HistoryViewModelTests.cs
Normal 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
|
||||
}
|
||||
374
tests/HihaArvio.Tests/ViewModels/MainViewModelTests.cs
Normal file
374
tests/HihaArvio.Tests/ViewModels/MainViewModelTests.cs
Normal 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
|
||||
}
|
||||
222
tests/HihaArvio.Tests/ViewModels/SettingsViewModelTests.cs
Normal file
222
tests/HihaArvio.Tests/ViewModels/SettingsViewModelTests.cs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user