From dd3a4f3d97bbfb7f6bd66f424daf27ec094b8631 Mon Sep 17 00:00:00 2001 From: Ismo Vuorinen Date: Tue, 18 Nov 2025 12:37:20 +0200 Subject: [PATCH] feat: implement services layer with TDD (Milestone 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented all core services following strict TDD (RED-GREEN-REFACTOR): **Services Implemented:** - EstimateService: Generate estimates with intensity-based range selection - StorageService: SQLite persistence with auto-pruning - ShakeDetectionService: Accelerometer-based shake detection **Features:** - Cryptographically secure RNG for estimate selection - Easter egg logic (>15s shake → Humorous mode) - Intensity-based range calculation (0-0.3: 20%, 0.3-0.7: 50%, 0.7+: 100%) - SQLite with auto-pruning based on MaxHistorySize - Shake detection with 1.5g threshold - Duration tracking and intensity normalization (0.0-1.0) - Event-based notifications (ShakeDataChanged) **Tests:** - EstimateService: 25 tests (RED-GREEN-REFACTOR) - StorageService: 14 tests (RED-GREEN-REFACTOR) - ShakeDetectionService: 22 tests (RED-GREEN-REFACTOR) - Integration tests: 10 tests - Total: 119 tests, all passing **Quality:** - Build: 0 warnings, 0 errors across all platforms - Coverage: 51.28% line (low due to MAUI template), 87.5% branch - All service/model code has high coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 54 +++ src/HihaArvio/Services/EstimateService.cs | 86 ++++ .../Services/Interfaces/IEstimateService.cs | 25 ++ .../Interfaces/IShakeDetectionService.cs | 48 +++ .../Services/Interfaces/IStorageService.cs | 46 +++ .../Services/ShakeDetectionService.cs | 136 +++++++ src/HihaArvio/Services/StorageService.cs | 175 ++++++++ .../Integration/ServiceIntegrationTests.cs | 321 +++++++++++++++ .../Services/EstimateServiceTests.cs | 314 +++++++++++++++ .../Services/ShakeDetectionServiceTests.cs | 373 ++++++++++++++++++ .../Services/StorageServiceTests.cs | 283 +++++++++++++ 11 files changed, 1861 insertions(+) create mode 100644 src/HihaArvio/Services/EstimateService.cs create mode 100644 src/HihaArvio/Services/Interfaces/IEstimateService.cs create mode 100644 src/HihaArvio/Services/Interfaces/IShakeDetectionService.cs create mode 100644 src/HihaArvio/Services/Interfaces/IStorageService.cs create mode 100644 src/HihaArvio/Services/ShakeDetectionService.cs create mode 100644 src/HihaArvio/Services/StorageService.cs create mode 100644 tests/HihaArvio.Tests/Integration/ServiceIntegrationTests.cs create mode 100644 tests/HihaArvio.Tests/Services/EstimateServiceTests.cs create mode 100644 tests/HihaArvio.Tests/Services/ShakeDetectionServiceTests.cs create mode 100644 tests/HihaArvio.Tests/Services/StorageServiceTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 92b6ce5..310153f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,6 +197,60 @@ HihaArvio.sln - Code analysis: StyleCop + built-in analyzers enabled - CI/CD must enforce 95% coverage threshold and fail builds below this +## Implementation Status + +### Completed Milestones + +**Milestone 1: Project Setup & Core Models (✅ Complete)** +- Solution structure with src/HihaArvio and tests/HihaArvio.Tests +- Core models: EstimateMode, EstimateResult, ShakeData, AppSettings +- 48 tests, all passing +- Build verification: all platforms (net8.0, iOS, macOS) + +**Milestone 2: Services Layer (✅ Complete)** +- IEstimateService + EstimateService (25 tests) + - Estimate generation with intensity-based range selection + - Easter egg logic (>15s → Humorous mode) + - Cryptographically secure RNG +- IStorageService + StorageService (14 tests) + - SQLite-based persistence + - Settings and history storage + - Auto-pruning based on MaxHistorySize +- IShakeDetectionService + ShakeDetectionService (22 tests) + - Shake detection with 1.5g threshold + - Intensity calculation and normalization (0.0-1.0) + - Duration tracking + - Event-based notification (ShakeDataChanged) +- Integration tests (10 tests) + - Service interaction verification + - Full flow: shake → estimate → storage +- **Total: 119 tests, all passing** +- **Coverage:** 51.28% line (low due to MAUI template), 87.5% branch +- **Build:** 0 warnings, 0 errors across all platforms + +### Remaining Work + +**Milestone 3: ViewModels Layer** (Not Started) +- MainViewModel (estimate display, settings) +- HistoryViewModel (estimate history) +- SettingsViewModel (mode selection, history management) + +**Milestone 4: Views/UI Layer** (Not Started) +- MainPage (shake screen, estimate display) +- HistoryPage (estimate list) +- SettingsPage (mode toggle, history clear) +- Navigation infrastructure + +**Milestone 5: Platform-Specific Implementations** (Not Started) +- IAccelerometerService interface +- iOS implementation (real accelerometer) +- Desktop/Web implementation (mouse movement simulation) + +**Milestone 6: Integration & Polish** (Not Started) +- Platform testing +- Performance optimization +- Documentation finalization + ## Important Implementation Order Per design document, phased development: diff --git a/src/HihaArvio/Services/EstimateService.cs b/src/HihaArvio/Services/EstimateService.cs new file mode 100644 index 0000000..2dc2049 --- /dev/null +++ b/src/HihaArvio/Services/EstimateService.cs @@ -0,0 +1,86 @@ +using System.Security.Cryptography; +using HihaArvio.Models; +using HihaArvio.Services.Interfaces; + +namespace HihaArvio.Services; + +/// +/// Service for generating time estimates based on shake data. +/// Implements intensity-based range selection and easter egg logic. +/// +public class EstimateService : IEstimateService +{ + // Per spec: Work mode estimates (wider ranges) + private static readonly string[] WorkEstimates = + { + // Gentle shake range (first 20%) + "2 hours", "4 hours", + // Medium range (first 50%) + "1 day", "2 days", "3 days", "5 days", "1 week", + // Full range (entire pool) + "15 minutes", "30 minutes", "1 hour", "2 weeks", "1 month", "3 months", "6 months", "1 year" + }; + + // Per spec: Generic mode estimates (wider ranges) + private static readonly string[] GenericEstimates = + { + // Gentle shake range (first 20%) + "1 minute", "5 minutes", "10 minutes", + // Medium range (first 50%) + "15 minutes", "30 minutes", "1 hour", "2 hours", "3 hours", + // Full range (entire pool) + "30 seconds", "6 hours", "12 hours", "1 day", "3 days", "1 week", "2 weeks", "1 month" + }; + + // Per spec: Humorous mode estimates (easter egg) + private static readonly string[] HumorousEstimates = + { + "5 minutes", "tomorrow", "eventually", "next quarter", + "when hell freezes over", "3 lifetimes", "Tuesday", "never", "your retirement" + }; + + /// + public EstimateResult GenerateEstimate(double intensity, TimeSpan duration, EstimateMode mode) + { + // Per spec: Easter egg - duration > 15 seconds forces Humorous mode + if (duration > TimeSpan.FromSeconds(15)) + { + mode = EstimateMode.Humorous; + } + + // Select estimate pool based on mode + var pool = mode switch + { + EstimateMode.Work => WorkEstimates, + EstimateMode.Generic => GenericEstimates, + EstimateMode.Humorous => HumorousEstimates, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Invalid estimate mode") + }; + + // Calculate range based on intensity (per spec) + var rangeSize = intensity switch + { + < 0.3 => (int)Math.Ceiling(pool.Length * 0.2), // First 20% (narrow range) + < 0.7 => (int)Math.Ceiling(pool.Length * 0.5), // First 50% (medium range) + _ => pool.Length // Entire pool (full range) + }; + + // Ensure at least one item in range + rangeSize = Math.Max(1, rangeSize); + + // Select random estimate from calculated range using cryptographically secure RNG (per spec) + var selectedEstimate = SelectRandomFromRange(pool, rangeSize); + + // Create and return EstimateResult with all metadata + return EstimateResult.Create(selectedEstimate, mode, intensity, duration); + } + + /// + /// Selects a random item from the first N items of the array using cryptographically secure RNG. + /// + private static string SelectRandomFromRange(string[] array, int rangeSize) + { + var index = RandomNumberGenerator.GetInt32(0, rangeSize); + return array[index]; + } +} diff --git a/src/HihaArvio/Services/Interfaces/IEstimateService.cs b/src/HihaArvio/Services/Interfaces/IEstimateService.cs new file mode 100644 index 0000000..add2d20 --- /dev/null +++ b/src/HihaArvio/Services/Interfaces/IEstimateService.cs @@ -0,0 +1,25 @@ +using HihaArvio.Models; + +namespace HihaArvio.Services.Interfaces; + +/// +/// Service for generating time estimates based on shake data. +/// +public interface IEstimateService +{ + /// + /// Generates a time estimate based on shake intensity, duration, and selected mode. + /// + /// Normalized shake intensity (0.0 to 1.0). + /// Duration of the shake gesture. + /// The estimation mode (Work, Generic, or Humorous). + /// An EstimateResult with the generated estimate and metadata. + /// + /// Per spec: If duration exceeds 15 seconds, mode is automatically changed to Humorous (easter egg). + /// Intensity determines the range of possible estimates: + /// - Low (0.0-0.3): narrow range (first 20% of pool) + /// - Medium (0.3-0.7): medium range (first 50% of pool) + /// - High (0.7-1.0): full range (entire pool) + /// + EstimateResult GenerateEstimate(double intensity, TimeSpan duration, EstimateMode mode); +} diff --git a/src/HihaArvio/Services/Interfaces/IShakeDetectionService.cs b/src/HihaArvio/Services/Interfaces/IShakeDetectionService.cs new file mode 100644 index 0000000..f811162 --- /dev/null +++ b/src/HihaArvio/Services/Interfaces/IShakeDetectionService.cs @@ -0,0 +1,48 @@ +using HihaArvio.Models; + +namespace HihaArvio.Services.Interfaces; + +/// +/// Service for detecting shake gestures from accelerometer data. +/// Implements shake detection algorithm with intensity calculation. +/// +public interface IShakeDetectionService +{ + /// + /// Gets the current shake data (intensity, duration, isShaking status). + /// + ShakeData CurrentShakeData { get; } + + /// + /// Starts monitoring for shake gestures. + /// + void StartMonitoring(); + + /// + /// Stops monitoring for shake gestures. + /// + void StopMonitoring(); + + /// + /// Gets whether monitoring is currently active. + /// + bool IsMonitoring { get; } + + /// + /// Processes accelerometer reading and updates shake detection state. + /// + /// X-axis acceleration in g's. + /// Y-axis acceleration in g's. + /// Z-axis acceleration in g's. + void ProcessAccelerometerReading(double x, double y, double z); + + /// + /// Resets the shake detection state (useful after generating an estimate). + /// + void Reset(); + + /// + /// Event raised when shake data changes (intensity, duration, or isShaking status). + /// + event EventHandler? ShakeDataChanged; +} diff --git a/src/HihaArvio/Services/Interfaces/IStorageService.cs b/src/HihaArvio/Services/Interfaces/IStorageService.cs new file mode 100644 index 0000000..8fc0128 --- /dev/null +++ b/src/HihaArvio/Services/Interfaces/IStorageService.cs @@ -0,0 +1,46 @@ +using HihaArvio.Models; + +namespace HihaArvio.Services.Interfaces; + +/// +/// Service for persisting application settings and estimate history. +/// +public interface IStorageService +{ + /// + /// Saves application settings. + /// + /// The settings to save. + Task SaveSettingsAsync(AppSettings settings); + + /// + /// Loads application settings. + /// + /// The loaded settings, or default settings if none exist. + Task LoadSettingsAsync(); + + /// + /// Saves an estimate result to history. + /// Automatically prunes history if it exceeds the maximum size. + /// + /// The estimate to save. + Task SaveEstimateAsync(EstimateResult estimate); + + /// + /// Gets estimate history, ordered by timestamp (newest first). + /// + /// Maximum number of estimates to retrieve (default 10). + /// List of estimate results, newest first. + Task> GetHistoryAsync(int count = 10); + + /// + /// Clears all estimate history. + /// + Task ClearHistoryAsync(); + + /// + /// Gets the current count of estimates in history. + /// + /// The number of estimates in storage. + Task GetHistoryCountAsync(); +} diff --git a/src/HihaArvio/Services/ShakeDetectionService.cs b/src/HihaArvio/Services/ShakeDetectionService.cs new file mode 100644 index 0000000..ce17b3f --- /dev/null +++ b/src/HihaArvio/Services/ShakeDetectionService.cs @@ -0,0 +1,136 @@ +using HihaArvio.Models; +using HihaArvio.Services.Interfaces; + +namespace HihaArvio.Services; + +/// +/// Service for detecting shake gestures from accelerometer data. +/// Implements shake detection algorithm per spec with 1.5g threshold and 4g max intensity. +/// +public class ShakeDetectionService : IShakeDetectionService +{ + // Per spec: 1.5g threshold for shake detection + private const double ShakeThresholdG = 1.5; + + // Per spec: 4g is maximum expected shake intensity for normalization + private const double MaxShakeIntensityG = 4.0; + + // Gravity constant (at rest, device experiences 1g) + private const double GravityG = 1.0; + + private ShakeData _currentShakeData; + private bool _isMonitoring; + private DateTimeOffset _shakeStartTime; + private bool _wasShakingLastUpdate; + + public ShakeDetectionService() + { + _currentShakeData = new ShakeData + { + IsShaking = false, + Intensity = 0.0, + Duration = TimeSpan.Zero + }; + _isMonitoring = false; + _wasShakingLastUpdate = false; + } + + /// + public ShakeData CurrentShakeData => _currentShakeData; + + /// + public bool IsMonitoring => _isMonitoring; + + /// + public event EventHandler? ShakeDataChanged; + + /// + public void StartMonitoring() + { + _isMonitoring = true; + } + + /// + public void StopMonitoring() + { + _isMonitoring = false; + } + + /// + public void Reset() + { + _currentShakeData = new ShakeData + { + IsShaking = false, + Intensity = 0.0, + Duration = TimeSpan.Zero + }; + _wasShakingLastUpdate = false; + } + + /// + public void ProcessAccelerometerReading(double x, double y, double z) + { + if (!_isMonitoring) + { + return; + } + + // Calculate magnitude of acceleration vector + var magnitude = Math.Sqrt(x * x + y * y + z * z); + + // Subtract gravity to get shake acceleration (device at rest = 1g) + var shakeAcceleration = Math.Max(0, magnitude - GravityG); + + // Determine if shaking based on threshold + var isShaking = shakeAcceleration >= ShakeThresholdG; + + // Normalize intensity to 0.0-1.0 range based on max expected shake + var normalizedIntensity = isShaking + ? Math.Min(1.0, shakeAcceleration / MaxShakeIntensityG) + : 0.0; + + // Track shake duration + TimeSpan duration; + if (isShaking) + { + if (!_wasShakingLastUpdate) + { + // Shake just started - reset start time + _shakeStartTime = DateTimeOffset.UtcNow; + duration = TimeSpan.Zero; + } + else + { + // Shake continuing - calculate duration + duration = DateTimeOffset.UtcNow - _shakeStartTime; + } + } + else + { + // Not shaking - reset duration + duration = TimeSpan.Zero; + } + + // Check if state changed + var stateChanged = isShaking != _wasShakingLastUpdate || + Math.Abs(normalizedIntensity - _currentShakeData.Intensity) > 0.01 || + duration != _currentShakeData.Duration; + + // Update current state + _currentShakeData = new ShakeData + { + IsShaking = isShaking, + Intensity = normalizedIntensity, + Duration = duration + }; + + _wasShakingLastUpdate = isShaking; + + // Fire event if state changed + if (stateChanged) + { + ShakeDataChanged?.Invoke(this, _currentShakeData); + } + } +} diff --git a/src/HihaArvio/Services/StorageService.cs b/src/HihaArvio/Services/StorageService.cs new file mode 100644 index 0000000..b25c723 --- /dev/null +++ b/src/HihaArvio/Services/StorageService.cs @@ -0,0 +1,175 @@ +using HihaArvio.Models; +using HihaArvio.Services.Interfaces; +using SQLite; + +namespace HihaArvio.Services; + +/// +/// SQLite-based storage service for persisting application settings and estimate history. +/// +public class StorageService : IStorageService +{ + private readonly SQLiteAsyncConnection _database; + + public StorageService(string databasePath) + { + _database = new SQLiteAsyncConnection(databasePath); + InitializeDatabaseAsync().Wait(); + } + + private async Task InitializeDatabaseAsync() + { + await _database.CreateTableAsync(); + await _database.CreateTableAsync(); + } + + /// + public async Task SaveSettingsAsync(AppSettings settings) + { + var entity = new SettingsEntity + { + Id = 1, // Single settings row + SelectedMode = settings.SelectedMode, + MaxHistorySize = settings.MaxHistorySize + }; + + var existing = await _database.Table().FirstOrDefaultAsync(s => s.Id == 1); + if (existing != null) + { + await _database.UpdateAsync(entity); + } + else + { + await _database.InsertAsync(entity); + } + } + + /// + public async Task LoadSettingsAsync() + { + var entity = await _database.Table().FirstOrDefaultAsync(s => s.Id == 1); + + if (entity == null) + { + // Return default settings if none exist + return new AppSettings + { + SelectedMode = EstimateMode.Work, + MaxHistorySize = 10 + }; + } + + return new AppSettings + { + SelectedMode = entity.SelectedMode, + MaxHistorySize = entity.MaxHistorySize + }; + } + + /// + public async Task SaveEstimateAsync(EstimateResult estimate) + { + var entity = new EstimateHistoryEntity + { + Id = estimate.Id.ToString(), + Timestamp = estimate.Timestamp, + EstimateText = estimate.EstimateText, + Mode = estimate.Mode, + ShakeIntensity = estimate.ShakeIntensity, + ShakeDuration = estimate.ShakeDuration + }; + + await _database.InsertAsync(entity); + + // Auto-prune based on MaxHistorySize setting + var settings = await LoadSettingsAsync(); + var count = await GetHistoryCountAsync(); + + if (count > settings.MaxHistorySize) + { + var excessCount = count - settings.MaxHistorySize; + var oldestEstimates = await _database.Table() + .OrderBy(e => e.Timestamp) + .Take(excessCount) + .ToListAsync(); + + foreach (var old in oldestEstimates) + { + await _database.DeleteAsync(old); + } + } + } + + /// + public async Task> GetHistoryAsync(int count = 10) + { + if (count <= 0) + { + return new List(); + } + + var entities = await _database.Table() + .OrderByDescending(e => e.Timestamp) + .Take(count) + .ToListAsync(); + + return entities.Select(e => + { + var result = EstimateResult.Create( + e.EstimateText, + e.Mode, + e.ShakeIntensity, + e.ShakeDuration + ); + result.Id = Guid.Parse(e.Id); + result.Timestamp = e.Timestamp; + return result; + }).ToList(); + } + + /// + public async Task ClearHistoryAsync() + { + await _database.DeleteAllAsync(); + } + + /// + public async Task GetHistoryCountAsync() + { + return await _database.Table().CountAsync(); + } + + /// + /// Internal entity for storing settings in SQLite. + /// + [Table("Settings")] + private class SettingsEntity + { + [PrimaryKey] + public int Id { get; set; } + + public EstimateMode SelectedMode { get; set; } + + public int MaxHistorySize { get; set; } + } + + /// + /// Internal entity for storing estimate history in SQLite. + /// + [Table("EstimateHistory")] + private class EstimateHistoryEntity + { + [PrimaryKey] + public string Id { get; set; } = string.Empty; + + public DateTimeOffset Timestamp { get; set; } + + public string EstimateText { get; set; } = string.Empty; + + public EstimateMode Mode { get; set; } + + public double ShakeIntensity { get; set; } + + public TimeSpan ShakeDuration { get; set; } + } +} diff --git a/tests/HihaArvio.Tests/Integration/ServiceIntegrationTests.cs b/tests/HihaArvio.Tests/Integration/ServiceIntegrationTests.cs new file mode 100644 index 0000000..3642252 --- /dev/null +++ b/tests/HihaArvio.Tests/Integration/ServiceIntegrationTests.cs @@ -0,0 +1,321 @@ +using FluentAssertions; +using HihaArvio.Models; +using HihaArvio.Services; +using HihaArvio.Services.Interfaces; + +namespace HihaArvio.Tests.Integration; + +/// +/// Integration tests verifying that services work together correctly. +/// +public class ServiceIntegrationTests : IDisposable +{ + private readonly IEstimateService _estimateService; + private readonly IStorageService _storageService; + private readonly IShakeDetectionService _shakeDetectionService; + private readonly string _testDbPath; + + public ServiceIntegrationTests() + { + _estimateService = new EstimateService(); + _testDbPath = Path.Combine(Path.GetTempPath(), $"test_integration_{Guid.NewGuid()}.db"); + _storageService = new StorageService(_testDbPath); + _shakeDetectionService = new ShakeDetectionService(); + } + + public void Dispose() + { + if (File.Exists(_testDbPath)) + { + File.Delete(_testDbPath); + } + } + + #region EstimateService + StorageService Integration + + [Fact] + public async Task GenerateAndSaveEstimate_ShouldPersistToStorage() + { + // Arrange + var intensity = 0.75; + var duration = TimeSpan.FromSeconds(5); + var mode = EstimateMode.Work; + + // Act - Generate estimate + var estimate = _estimateService.GenerateEstimate(intensity, duration, mode); + + // Save to storage + await _storageService.SaveEstimateAsync(estimate); + + // Load from storage + var history = await _storageService.GetHistoryAsync(); + + // Assert + history.Should().ContainSingle(); + var saved = history.First(); + saved.Id.Should().Be(estimate.Id); + saved.EstimateText.Should().Be(estimate.EstimateText); + saved.Mode.Should().Be(estimate.Mode); + saved.ShakeIntensity.Should().Be(estimate.ShakeIntensity); + saved.ShakeDuration.Should().Be(estimate.ShakeDuration); + } + + [Fact] + public async Task GenerateMultipleEstimates_ShouldRespectHistoryLimit() + { + // Arrange + var settings = new AppSettings { MaxHistorySize = 3 }; + await _storageService.SaveSettingsAsync(settings); + + // Act - Generate and save 5 estimates + for (int i = 0; i < 5; i++) + { + var estimate = _estimateService.GenerateEstimate(0.5, TimeSpan.FromSeconds(5), EstimateMode.Work); + await _storageService.SaveEstimateAsync(estimate); + await Task.Delay(10); // Ensure different timestamps + } + + // Assert - Should only keep 3 newest + var count = await _storageService.GetHistoryCountAsync(); + count.Should().Be(3); + } + + [Fact] + public async Task LoadSettings_GenerateEstimate_ShouldUseCorrectMode() + { + // Arrange - Save settings with Generic mode + var settings = new AppSettings { SelectedMode = EstimateMode.Generic }; + await _storageService.SaveSettingsAsync(settings); + + // Act - Load settings and generate estimate + var loadedSettings = await _storageService.LoadSettingsAsync(); + var estimate = _estimateService.GenerateEstimate(0.5, TimeSpan.FromSeconds(5), loadedSettings.SelectedMode); + + // Assert + estimate.Mode.Should().Be(EstimateMode.Generic); + } + + #endregion + + #region ShakeDetectionService + EstimateService Integration + + [Fact] + public void DetectShake_GenerateEstimate_ShouldUseShakeData() + { + // Arrange + _shakeDetectionService.StartMonitoring(); + + // Act - Simulate shake + _shakeDetectionService.ProcessAccelerometerReading(3.0, 2.5, 2.0); + var shakeData = _shakeDetectionService.CurrentShakeData; + + // Generate estimate based on shake + var estimate = _estimateService.GenerateEstimate( + shakeData.Intensity, + shakeData.Duration, + EstimateMode.Work); + + // Assert + estimate.ShakeIntensity.Should().Be(shakeData.Intensity); + estimate.ShakeDuration.Should().Be(shakeData.Duration); + estimate.EstimateText.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void DetectLongShake_GenerateEstimate_ShouldTriggerHumorousMode() + { + // Arrange + _shakeDetectionService.StartMonitoring(); + + // Act - Simulate shake and manually set long duration for test + _shakeDetectionService.ProcessAccelerometerReading(2.5, 2.0, 1.5); + + // Simulate 16-second shake by generating estimate with long duration + var estimate = _estimateService.GenerateEstimate( + 0.5, + TimeSpan.FromSeconds(16), // Exceeds 15-second threshold + EstimateMode.Work); + + // Assert - Should switch to Humorous mode (easter egg) + estimate.Mode.Should().Be(EstimateMode.Humorous); + estimate.EstimateText.Should().BeOneOf( + "5 minutes", "tomorrow", "eventually", "next quarter", + "when hell freezes over", "3 lifetimes", "Tuesday", "never", "your retirement"); + } + + [Fact] + public void DetectVaryingIntensity_GenerateEstimates_ShouldProduceDifferentRanges() + { + // Arrange + _shakeDetectionService.StartMonitoring(); + + // Act - Test low, medium, and high intensity shakes + var estimates = new List(); + + // Low intensity shake + _shakeDetectionService.ProcessAccelerometerReading(1.8, 1.5, 0.5); + var lowShake = _shakeDetectionService.CurrentShakeData; + lowShake.Intensity.Should().BeLessThan(0.3); + estimates.Add(_estimateService.GenerateEstimate(lowShake.Intensity, lowShake.Duration, EstimateMode.Work)); + + // High intensity shake + _shakeDetectionService.Reset(); + _shakeDetectionService.ProcessAccelerometerReading(4.0, 3.5, 3.0); + var highShake = _shakeDetectionService.CurrentShakeData; + highShake.Intensity.Should().BeGreaterThan(0.7); + estimates.Add(_estimateService.GenerateEstimate(highShake.Intensity, highShake.Duration, EstimateMode.Work)); + + // Assert - Both should generate valid estimates + estimates.Should().AllSatisfy(e => + { + e.EstimateText.Should().NotBeNullOrEmpty(); + e.Mode.Should().Be(EstimateMode.Work); + }); + } + + #endregion + + #region Full Flow Integration + + [Fact] + public async Task CompleteFlow_ShakeToEstimateToStorage_ShouldWorkEndToEnd() + { + // Arrange + var settings = await _storageService.LoadSettingsAsync(); + _shakeDetectionService.StartMonitoring(); + + // Act - Simulate shake gesture + _shakeDetectionService.ProcessAccelerometerReading(3.0, 2.5, 2.0); + var shakeData = _shakeDetectionService.CurrentShakeData; + + shakeData.IsShaking.Should().BeTrue(); + + // Generate estimate from shake + var estimate = _estimateService.GenerateEstimate( + shakeData.Intensity, + shakeData.Duration, + settings.SelectedMode); + + // Save to storage + await _storageService.SaveEstimateAsync(estimate); + + // Load from storage + var history = await _storageService.GetHistoryAsync(); + + // Assert - Complete flow succeeded + history.Should().ContainSingle(); + var saved = history.First(); + saved.Id.Should().Be(estimate.Id); + saved.EstimateText.Should().Be(estimate.EstimateText); + saved.ShakeIntensity.Should().Be(shakeData.Intensity); + + // Reset for next shake + _shakeDetectionService.Reset(); + _shakeDetectionService.CurrentShakeData.IsShaking.Should().BeFalse(); + } + + [Fact] + public async Task MultipleShakes_ShouldAccumulateHistory() + { + // Arrange + _shakeDetectionService.StartMonitoring(); + + // Act - Simulate 3 shake gestures + for (int i = 0; i < 3; i++) + { + // Shake + _shakeDetectionService.ProcessAccelerometerReading(2.5, 2.0, 1.5); + var shakeData = _shakeDetectionService.CurrentShakeData; + + // Generate estimate + var estimate = _estimateService.GenerateEstimate( + shakeData.Intensity, + shakeData.Duration, + EstimateMode.Work); + + // Save + await _storageService.SaveEstimateAsync(estimate); + + // Reset for next shake + _shakeDetectionService.Reset(); + + await Task.Delay(10); // Ensure different timestamps + } + + // Assert + var history = await _storageService.GetHistoryAsync(); + history.Should().HaveCount(3); + + // Verify ordering (newest first) + for (int i = 0; i < history.Count - 1; i++) + { + history[i].Timestamp.Should().BeOnOrAfter(history[i + 1].Timestamp); + } + } + + [Fact] + public async Task ChangeMode_GenerateEstimate_ShouldUseNewMode() + { + // Arrange + var initialSettings = new AppSettings { SelectedMode = EstimateMode.Work }; + await _storageService.SaveSettingsAsync(initialSettings); + + // Act - Generate estimate with Work mode + var workEstimate = _estimateService.GenerateEstimate(0.5, TimeSpan.FromSeconds(5), EstimateMode.Work); + await _storageService.SaveEstimateAsync(workEstimate); + + // Change mode + var newSettings = new AppSettings { SelectedMode = EstimateMode.Humorous }; + await _storageService.SaveSettingsAsync(newSettings); + + // Generate estimate with new mode + var humorousEstimate = _estimateService.GenerateEstimate(0.5, TimeSpan.FromSeconds(5), EstimateMode.Humorous); + await _storageService.SaveEstimateAsync(humorousEstimate); + + // Assert + var history = await _storageService.GetHistoryAsync(); + history.Should().HaveCount(2); + history[0].Mode.Should().Be(EstimateMode.Humorous); // Newest + history[1].Mode.Should().Be(EstimateMode.Work); // Oldest + } + + #endregion + + #region Event Integration + + [Fact] + public async Task ShakeEvent_ShouldTriggerEstimateGeneration() + { + // Arrange + _shakeDetectionService.StartMonitoring(); + EstimateResult? generatedEstimate = null; + + _shakeDetectionService.ShakeDataChanged += async (sender, shakeData) => + { + if (shakeData.IsShaking) + { + // Generate estimate when shake detected + generatedEstimate = _estimateService.GenerateEstimate( + shakeData.Intensity, + shakeData.Duration, + EstimateMode.Work); + + await _storageService.SaveEstimateAsync(generatedEstimate); + } + }; + + // Act - Simulate shake + _shakeDetectionService.ProcessAccelerometerReading(3.0, 2.5, 2.0); + + // Give event handler time to execute + await Task.Delay(50); + + // Assert + generatedEstimate.Should().NotBeNull(); + var history = await _storageService.GetHistoryAsync(); + history.Should().ContainSingle(); + history.First().Id.Should().Be(generatedEstimate!.Id); + } + + #endregion +} diff --git a/tests/HihaArvio.Tests/Services/EstimateServiceTests.cs b/tests/HihaArvio.Tests/Services/EstimateServiceTests.cs new file mode 100644 index 0000000..8a00f28 --- /dev/null +++ b/tests/HihaArvio.Tests/Services/EstimateServiceTests.cs @@ -0,0 +1,314 @@ +using FluentAssertions; +using HihaArvio.Models; +using HihaArvio.Services; +using HihaArvio.Services.Interfaces; + +namespace HihaArvio.Tests.Services; + +public class EstimateServiceTests +{ + private readonly IEstimateService _service; + + public EstimateServiceTests() + { + _service = new EstimateService(); + } + + #region Easter Egg Tests (>15 seconds → Humorous mode) + + [Fact] + public void GenerateEstimate_WhenDurationExceeds15Seconds_ShouldForceHumorousMode() + { + // Arrange + var duration = TimeSpan.FromSeconds(16); + + // Act + var result = _service.GenerateEstimate(0.5, duration, EstimateMode.Work); + + // Assert + result.Mode.Should().Be(EstimateMode.Humorous); + result.EstimateText.Should().BeOneOf( + "5 minutes", "tomorrow", "eventually", "next quarter", + "when hell freezes over", "3 lifetimes", "Tuesday", "never", "your retirement"); + } + + [Fact] + public void GenerateEstimate_WhenDurationExactly15Seconds_ShouldNotTriggerEasterEgg() + { + // Arrange + var duration = TimeSpan.FromSeconds(15); + + // Act + var result = _service.GenerateEstimate(0.5, duration, EstimateMode.Work); + + // Assert + result.Mode.Should().Be(EstimateMode.Work); + } + + [Fact] + public void GenerateEstimate_WhenDurationBelowThreshold_ShouldRespectOriginalMode() + { + // Arrange + var duration = TimeSpan.FromSeconds(10); + + // Act + var result = _service.GenerateEstimate(0.5, duration, EstimateMode.Generic); + + // Assert + result.Mode.Should().Be(EstimateMode.Generic); + } + + #endregion + + #region Intensity-Based Range Selection Tests + + [Theory] + [InlineData(0.0, EstimateMode.Work)] // Lowest intensity + [InlineData(0.1, EstimateMode.Work)] + [InlineData(0.29, EstimateMode.Work)] + [InlineData(0.0, EstimateMode.Generic)] + [InlineData(0.2, EstimateMode.Generic)] + public void GenerateEstimate_WithLowIntensity_ShouldReturnFromNarrowRange(double intensity, EstimateMode mode) + { + // Arrange + var duration = TimeSpan.FromSeconds(5); + + // Act - Generate multiple estimates to test range + var results = Enumerable.Range(0, 50) + .Select(_ => _service.GenerateEstimate(intensity, duration, mode)) + .ToList(); + + // Assert - All results should be from the narrow range (first 20% of pool) + // We can't test exact values without knowing implementation, but we can verify consistency + results.Should().AllSatisfy(r => + { + r.Mode.Should().Be(mode); + r.ShakeIntensity.Should().Be(intensity); + r.EstimateText.Should().NotBeNullOrEmpty(); + }); + + // The variety should be limited (narrow range) + var uniqueEstimates = results.Select(r => r.EstimateText).Distinct().Count(); + uniqueEstimates.Should().BeLessThan(10, "low intensity should produce limited variety"); + } + + [Theory] + [InlineData(0.3, EstimateMode.Work)] + [InlineData(0.5, EstimateMode.Work)] + [InlineData(0.69, EstimateMode.Work)] + [InlineData(0.4, EstimateMode.Generic)] + public void GenerateEstimate_WithMediumIntensity_ShouldReturnFromMediumRange(double intensity, EstimateMode mode) + { + // Arrange + var duration = TimeSpan.FromSeconds(5); + + // Act + var results = Enumerable.Range(0, 50) + .Select(_ => _service.GenerateEstimate(intensity, duration, mode)) + .ToList(); + + // Assert + results.Should().AllSatisfy(r => + { + r.Mode.Should().Be(mode); + r.ShakeIntensity.Should().Be(intensity); + }); + + // Medium range should have more variety than low + var uniqueEstimates = results.Select(r => r.EstimateText).Distinct().Count(); + uniqueEstimates.Should().BeGreaterThan(2, "medium intensity should produce moderate variety"); + } + + [Theory] + [InlineData(0.7, EstimateMode.Work)] + [InlineData(0.85, EstimateMode.Work)] + [InlineData(1.0, EstimateMode.Work)] + [InlineData(0.9, EstimateMode.Generic)] + public void GenerateEstimate_WithHighIntensity_ShouldReturnFromFullRange(double intensity, EstimateMode mode) + { + // Arrange + var duration = TimeSpan.FromSeconds(5); + + // Act + var results = Enumerable.Range(0, 100) + .Select(_ => _service.GenerateEstimate(intensity, duration, mode)) + .ToList(); + + // Assert + results.Should().AllSatisfy(r => + { + r.Mode.Should().Be(mode); + r.ShakeIntensity.Should().Be(intensity); + }); + + // High intensity should have maximum variety + var uniqueEstimates = results.Select(r => r.EstimateText).Distinct().Count(); + uniqueEstimates.Should().BeGreaterThan(5, "high intensity should produce maximum variety"); + } + + #endregion + + #region Mode-Specific Estimate Pool Tests + + [Fact] + public void GenerateEstimate_InWorkMode_ShouldReturnWorkEstimates() + { + // Arrange + var validWorkEstimates = new[] + { + "2 hours", "4 hours", "1 day", "2 days", "3 days", "5 days", "1 week", + "15 minutes", "30 minutes", "1 hour", "2 weeks", "1 month", "3 months", "6 months", "1 year" + }; + + // Act + var results = Enumerable.Range(0, 50) + .Select(_ => _service.GenerateEstimate(0.8, TimeSpan.FromSeconds(5), EstimateMode.Work)) + .ToList(); + + // Assert + results.Should().AllSatisfy(r => + { + r.EstimateText.Should().BeOneOf(validWorkEstimates); + r.Mode.Should().Be(EstimateMode.Work); + }); + } + + [Fact] + public void GenerateEstimate_InGenericMode_ShouldReturnGenericEstimates() + { + // Arrange + var validGenericEstimates = new[] + { + "1 minute", "5 minutes", "10 minutes", "15 minutes", "30 minutes", + "1 hour", "2 hours", "3 hours", "6 hours", "12 hours", + "1 day", "3 days", "1 week", "2 weeks", "1 month", "30 seconds" + }; + + // Act + var results = Enumerable.Range(0, 50) + .Select(_ => _service.GenerateEstimate(0.8, TimeSpan.FromSeconds(5), EstimateMode.Generic)) + .ToList(); + + // Assert + results.Should().AllSatisfy(r => + { + r.EstimateText.Should().BeOneOf(validGenericEstimates); + r.Mode.Should().Be(EstimateMode.Generic); + }); + } + + [Fact] + public void GenerateEstimate_InHumorousMode_ShouldReturnHumorousEstimates() + { + // Arrange + var validHumorousEstimates = new[] + { + "5 minutes", "tomorrow", "eventually", "next quarter", + "when hell freezes over", "3 lifetimes", "Tuesday", "never", "your retirement" + }; + + // Act + var results = Enumerable.Range(0, 30) + .Select(_ => _service.GenerateEstimate(0.5, TimeSpan.FromSeconds(5), EstimateMode.Humorous)) + .ToList(); + + // Assert + results.Should().AllSatisfy(r => + { + r.EstimateText.Should().BeOneOf(validHumorousEstimates); + r.Mode.Should().Be(EstimateMode.Humorous); + }); + } + + #endregion + + #region EstimateResult Metadata Tests + + [Fact] + public void GenerateEstimate_ShouldSetAllEstimateResultProperties() + { + // Arrange + var intensity = 0.75; + var duration = TimeSpan.FromSeconds(8); + var mode = EstimateMode.Work; + var before = DateTimeOffset.UtcNow; + + // Act + var result = _service.GenerateEstimate(intensity, duration, mode); + var after = DateTimeOffset.UtcNow; + + // Assert + result.Id.Should().NotBeEmpty(); + result.Timestamp.Should().BeOnOrAfter(before).And.BeOnOrBefore(after); + result.EstimateText.Should().NotBeNullOrEmpty(); + result.Mode.Should().Be(mode); + result.ShakeIntensity.Should().Be(intensity); + result.ShakeDuration.Should().Be(duration); + } + + [Fact] + public void GenerateEstimate_ShouldGenerateUniqueIds() + { + // Act + var result1 = _service.GenerateEstimate(0.5, TimeSpan.FromSeconds(5), EstimateMode.Work); + var result2 = _service.GenerateEstimate(0.5, TimeSpan.FromSeconds(5), EstimateMode.Work); + + // Assert + result1.Id.Should().NotBe(result2.Id); + } + + [Fact] + public void GenerateEstimate_ShouldProduceRandomResults() + { + // Act - Generate many estimates to verify randomness + var results = Enumerable.Range(0, 100) + .Select(_ => _service.GenerateEstimate(0.8, TimeSpan.FromSeconds(5), EstimateMode.Work)) + .Select(r => r.EstimateText) + .ToList(); + + // Assert - Should have multiple different estimates (not always the same) + var uniqueCount = results.Distinct().Count(); + uniqueCount.Should().BeGreaterThan(1, "service should produce varied random results"); + } + + #endregion + + #region Edge Cases + + [Fact] + public void GenerateEstimate_WithZeroIntensity_ShouldWork() + { + // Act + var result = _service.GenerateEstimate(0.0, TimeSpan.FromSeconds(5), EstimateMode.Work); + + // Assert + result.Should().NotBeNull(); + result.EstimateText.Should().NotBeNullOrEmpty(); + result.ShakeIntensity.Should().Be(0.0); + } + + [Fact] + public void GenerateEstimate_WithMaxIntensity_ShouldWork() + { + // Act + var result = _service.GenerateEstimate(1.0, TimeSpan.FromSeconds(5), EstimateMode.Work); + + // Assert + result.Should().NotBeNull(); + result.EstimateText.Should().NotBeNullOrEmpty(); + result.ShakeIntensity.Should().Be(1.0); + } + + [Fact] + public void GenerateEstimate_WithZeroDuration_ShouldWork() + { + // Act + var result = _service.GenerateEstimate(0.5, TimeSpan.Zero, EstimateMode.Work); + + // Assert + result.Should().NotBeNull(); + result.ShakeDuration.Should().Be(TimeSpan.Zero); + } + + #endregion +} diff --git a/tests/HihaArvio.Tests/Services/ShakeDetectionServiceTests.cs b/tests/HihaArvio.Tests/Services/ShakeDetectionServiceTests.cs new file mode 100644 index 0000000..3a87379 --- /dev/null +++ b/tests/HihaArvio.Tests/Services/ShakeDetectionServiceTests.cs @@ -0,0 +1,373 @@ +using FluentAssertions; +using HihaArvio.Models; +using HihaArvio.Services; +using HihaArvio.Services.Interfaces; + +namespace HihaArvio.Tests.Services; + +public class ShakeDetectionServiceTests +{ + private readonly IShakeDetectionService _service; + + public ShakeDetectionServiceTests() + { + _service = new ShakeDetectionService(); + } + + #region Initialization and State Tests + + [Fact] + public void Constructor_ShouldInitializeWithDefaultState() + { + // Assert + _service.IsMonitoring.Should().BeFalse(); + _service.CurrentShakeData.Should().NotBeNull(); + _service.CurrentShakeData.IsShaking.Should().BeFalse(); + _service.CurrentShakeData.Intensity.Should().Be(0.0); + _service.CurrentShakeData.Duration.Should().Be(TimeSpan.Zero); + } + + [Fact] + public void StartMonitoring_ShouldSetIsMonitoringToTrue() + { + // Act + _service.StartMonitoring(); + + // Assert + _service.IsMonitoring.Should().BeTrue(); + } + + [Fact] + public void StopMonitoring_ShouldSetIsMonitoringToFalse() + { + // Arrange + _service.StartMonitoring(); + + // Act + _service.StopMonitoring(); + + // Assert + _service.IsMonitoring.Should().BeFalse(); + } + + [Fact] + public void StartMonitoring_MultipleTimes_ShouldRemainsTrue() + { + // Act + _service.StartMonitoring(); + _service.StartMonitoring(); + _service.StartMonitoring(); + + // Assert + _service.IsMonitoring.Should().BeTrue(); + } + + [Fact] + public void Reset_ShouldClearShakeState() + { + // Arrange - Simulate shake + _service.StartMonitoring(); + _service.ProcessAccelerometerReading(3.0, 3.0, 3.0); // Strong shake + _service.CurrentShakeData.IsShaking.Should().BeTrue(); + + // Act + _service.Reset(); + + // Assert + _service.CurrentShakeData.IsShaking.Should().BeFalse(); + _service.CurrentShakeData.Intensity.Should().Be(0.0); + _service.CurrentShakeData.Duration.Should().Be(TimeSpan.Zero); + } + + #endregion + + #region Accelerometer Processing Tests + + [Fact] + public void ProcessAccelerometerReading_WhenNotMonitoring_ShouldNotUpdateState() + { + // Arrange + _service.StopMonitoring(); + + // Act + _service.ProcessAccelerometerReading(2.0, 2.0, 2.0); + + // Assert + _service.CurrentShakeData.IsShaking.Should().BeFalse(); + _service.CurrentShakeData.Intensity.Should().Be(0.0); + } + + [Fact] + public void ProcessAccelerometerReading_WithLowAcceleration_ShouldNotDetectShake() + { + // Arrange + _service.StartMonitoring(); + + // Act - Simulate device at rest (1g gravity) + _service.ProcessAccelerometerReading(0.0, 0.0, 1.0); + + // Assert + _service.CurrentShakeData.IsShaking.Should().BeFalse(); + _service.CurrentShakeData.Intensity.Should().Be(0.0); + } + + [Fact] + public void ProcessAccelerometerReading_WithModerateShake_ShouldDetectShake() + { + // Arrange + _service.StartMonitoring(); + + // Act - Simulate moderate shake (3g total acceleration, 2g shake after subtracting 1g gravity) + _service.ProcessAccelerometerReading(2.0, 1.5, 1.5); + + // Assert + _service.CurrentShakeData.IsShaking.Should().BeTrue(); + _service.CurrentShakeData.Intensity.Should().BeGreaterThan(0.0); + } + + [Fact] + public void ProcessAccelerometerReading_WithStrongShake_ShouldDetectHighIntensity() + { + // Arrange + _service.StartMonitoring(); + + // Act - Simulate strong shake (4g total acceleration) + _service.ProcessAccelerometerReading(3.0, 2.0, 1.5); + + // Assert + _service.CurrentShakeData.IsShaking.Should().BeTrue(); + _service.CurrentShakeData.Intensity.Should().BeGreaterThan(0.5); + } + + [Fact] + public void ProcessAccelerometerReading_ShouldNormalizeIntensityBetweenZeroAndOne() + { + // Arrange + _service.StartMonitoring(); + + // Act - Test various shake strengths + for (int i = 0; i < 50; i++) + { + var x = Random.Shared.NextDouble() * 6.0 - 3.0; + var y = Random.Shared.NextDouble() * 6.0 - 3.0; + var z = Random.Shared.NextDouble() * 6.0 - 3.0; + _service.ProcessAccelerometerReading(x, y, z); + + // Assert + _service.CurrentShakeData.Intensity.Should().BeInRange(0.0, 1.0); + } + } + + #endregion + + #region Shake Duration Tests + + [Fact] + public void ProcessAccelerometerReading_DuringShake_ShouldIncreaseDuration() + { + // Arrange + _service.StartMonitoring(); + + // Act - Simulate continuous shake + _service.ProcessAccelerometerReading(2.5, 2.0, 1.5); + var initialDuration = _service.CurrentShakeData.Duration; + + Thread.Sleep(100); // Wait 100ms + _service.ProcessAccelerometerReading(2.5, 2.0, 1.5); + var laterDuration = _service.CurrentShakeData.Duration; + + // Assert + laterDuration.Should().BeGreaterThan(initialDuration); + } + + [Fact] + public void ProcessAccelerometerReading_AfterShakeStops_ShouldResetDuration() + { + // Arrange + _service.StartMonitoring(); + + // Act - Start shake + _service.ProcessAccelerometerReading(2.5, 2.0, 1.5); + _service.CurrentShakeData.IsShaking.Should().BeTrue(); + Thread.Sleep(50); + _service.ProcessAccelerometerReading(2.5, 2.0, 1.5); + var shakeDuration = _service.CurrentShakeData.Duration; + shakeDuration.Should().BeGreaterThan(TimeSpan.Zero); + + // Stop shake + Thread.Sleep(50); + _service.ProcessAccelerometerReading(0.0, 0.0, 1.0); + _service.CurrentShakeData.IsShaking.Should().BeFalse(); + + // Start new shake + Thread.Sleep(50); + _service.ProcessAccelerometerReading(2.5, 2.0, 1.5); + + // Assert - Duration should reset for new shake + _service.CurrentShakeData.Duration.Should().BeLessThan(shakeDuration); + } + + #endregion + + #region Event Tests + + [Fact] + public void ShakeDataChanged_ShouldFireWhenShakeStarts() + { + // Arrange + _service.StartMonitoring(); + ShakeData? capturedData = null; + _service.ShakeDataChanged += (sender, data) => capturedData = data; + + // Act + _service.ProcessAccelerometerReading(2.5, 2.0, 1.5); + + // Assert + capturedData.Should().NotBeNull(); + capturedData!.IsShaking.Should().BeTrue(); + capturedData.Intensity.Should().BeGreaterThan(0.0); + } + + [Fact] + public void ShakeDataChanged_ShouldFireWhenShakeStops() + { + // Arrange + _service.StartMonitoring(); + _service.ProcessAccelerometerReading(2.5, 2.0, 1.5); // Start shake + + var eventFiredCount = 0; + ShakeData? lastCapturedData = null; + _service.ShakeDataChanged += (sender, data) => + { + eventFiredCount++; + lastCapturedData = data; + }; + + // Act - Stop shake + _service.ProcessAccelerometerReading(0.0, 0.0, 1.0); + + // Assert + eventFiredCount.Should().BeGreaterThan(0); + lastCapturedData.Should().NotBeNull(); + lastCapturedData!.IsShaking.Should().BeFalse(); + } + + [Fact] + public void ShakeDataChanged_ShouldFireWhenIntensityChanges() + { + // Arrange + _service.StartMonitoring(); + + var intensities = new List(); + _service.ShakeDataChanged += (sender, data) => intensities.Add(data.Intensity); + + // Act - Start with gentle shake, then increase intensity + _service.ProcessAccelerometerReading(2.0, 1.5, 1.0); // Gentle shake (~2.5g total, ~1.5g shake) + _service.ProcessAccelerometerReading(3.0, 2.5, 2.0); // Strong shake (~4.4g total, ~3.4g shake) + + // Assert + intensities.Should().HaveCountGreaterThanOrEqualTo(2); + intensities.Last().Should().BeGreaterThan(intensities.First()); + } + + [Fact] + public void ShakeDataChanged_WhenNotMonitoring_ShouldNotFire() + { + // Arrange + _service.StopMonitoring(); + var eventFired = false; + _service.ShakeDataChanged += (sender, data) => eventFired = true; + + // Act + _service.ProcessAccelerometerReading(2.5, 2.0, 1.5); + + // Assert + eventFired.Should().BeFalse(); + } + + #endregion + + #region Intensity Calculation Tests + + [Fact] + public void ProcessAccelerometerReading_ShouldCalculateIntensityFromMagnitude() + { + // Arrange + _service.StartMonitoring(); + + // Act & Assert - Test known values + // Magnitude = sqrt(3^2 + 4^2 + 0^2) = 5.0 + _service.ProcessAccelerometerReading(3.0, 4.0, 0.0); + _service.CurrentShakeData.IsShaking.Should().BeTrue(); + _service.CurrentShakeData.Intensity.Should().BeGreaterThan(0.5); + } + + [Fact] + public void ProcessAccelerometerReading_WithZeroAcceleration_ShouldHaveZeroIntensity() + { + // Arrange + _service.StartMonitoring(); + + // Act + _service.ProcessAccelerometerReading(0.0, 0.0, 0.0); + + // Assert + _service.CurrentShakeData.Intensity.Should().Be(0.0); + _service.CurrentShakeData.IsShaking.Should().BeFalse(); + } + + [Fact] + public void ProcessAccelerometerReading_WithNegativeValues_ShouldCalculateCorrectMagnitude() + { + // Arrange + _service.StartMonitoring(); + + // Act - Negative values should be squared, making them positive + _service.ProcessAccelerometerReading(-2.0, -2.0, -2.0); + + // Assert + _service.CurrentShakeData.IsShaking.Should().BeTrue(); + _service.CurrentShakeData.Intensity.Should().BeGreaterThan(0.0); + } + + #endregion + + #region Edge Cases + + [Fact] + public void Reset_WhenNotShaking_ShouldNotThrow() + { + // Act & Assert + var act = () => _service.Reset(); + act.Should().NotThrow(); + } + + [Fact] + public void StopMonitoring_WhenNotStarted_ShouldNotThrow() + { + // Act & Assert + var act = () => _service.StopMonitoring(); + act.Should().NotThrow(); + } + + [Fact] + public void ProcessAccelerometerReading_WithExtremeValues_ShouldNotThrow() + { + // Arrange + _service.StartMonitoring(); + + // Act & Assert + var act = () => + { + _service.ProcessAccelerometerReading(100.0, 100.0, 100.0); + _service.ProcessAccelerometerReading(-100.0, -100.0, -100.0); + _service.ProcessAccelerometerReading(double.MaxValue / 1000, 0, 0); + }; + act.Should().NotThrow(); + + // Intensity should still be normalized + _service.CurrentShakeData.Intensity.Should().BeInRange(0.0, 1.0); + } + + #endregion +} diff --git a/tests/HihaArvio.Tests/Services/StorageServiceTests.cs b/tests/HihaArvio.Tests/Services/StorageServiceTests.cs new file mode 100644 index 0000000..66018f8 --- /dev/null +++ b/tests/HihaArvio.Tests/Services/StorageServiceTests.cs @@ -0,0 +1,283 @@ +using FluentAssertions; +using HihaArvio.Models; +using HihaArvio.Services; +using HihaArvio.Services.Interfaces; + +namespace HihaArvio.Tests.Services; + +public class StorageServiceTests : IDisposable +{ + private readonly IStorageService _service; + private readonly string _testDbPath; + + public StorageServiceTests() + { + // Use unique temp file for each test to ensure isolation + _testDbPath = Path.Combine(Path.GetTempPath(), $"test_hiha_{Guid.NewGuid()}.db"); + _service = new StorageService(_testDbPath); + } + + public void Dispose() + { + // Clean up test database + if (File.Exists(_testDbPath)) + { + File.Delete(_testDbPath); + } + } + + #region Settings Tests + + [Fact] + public async Task LoadSettingsAsync_WhenNoSettingsExist_ShouldReturnDefaultSettings() + { + // Act + var settings = await _service.LoadSettingsAsync(); + + // Assert + settings.Should().NotBeNull(); + settings.SelectedMode.Should().Be(EstimateMode.Work); + settings.MaxHistorySize.Should().Be(10); + } + + [Fact] + public async Task SaveAndLoadSettings_ShouldPersistSettings() + { + // Arrange + var settingsToSave = new AppSettings + { + SelectedMode = EstimateMode.Generic, + MaxHistorySize = 20 + }; + + // Act + await _service.SaveSettingsAsync(settingsToSave); + var loadedSettings = await _service.LoadSettingsAsync(); + + // Assert + loadedSettings.SelectedMode.Should().Be(EstimateMode.Generic); + loadedSettings.MaxHistorySize.Should().Be(20); + } + + [Fact] + public async Task SaveSettings_MultipleTimes_ShouldOverwritePreviousSettings() + { + // Arrange + var settings1 = new AppSettings { SelectedMode = EstimateMode.Work, MaxHistorySize = 10 }; + var settings2 = new AppSettings { SelectedMode = EstimateMode.Humorous, MaxHistorySize = 50 }; + + // Act + await _service.SaveSettingsAsync(settings1); + await _service.SaveSettingsAsync(settings2); + var loaded = await _service.LoadSettingsAsync(); + + // Assert + loaded.SelectedMode.Should().Be(EstimateMode.Humorous); + loaded.MaxHistorySize.Should().Be(50); + } + + #endregion + + #region Estimate History Tests + + [Fact] + public async Task SaveEstimateAsync_ShouldPersistEstimate() + { + // Arrange + var estimate = EstimateResult.Create("2 weeks", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + + // Act + await _service.SaveEstimateAsync(estimate); + var history = await _service.GetHistoryAsync(); + + // Assert + history.Should().ContainSingle(); + var saved = history.First(); + saved.Id.Should().Be(estimate.Id); + saved.EstimateText.Should().Be("2 weeks"); + saved.Mode.Should().Be(EstimateMode.Work); + saved.ShakeIntensity.Should().Be(0.5); + saved.ShakeDuration.Should().Be(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task GetHistoryAsync_WhenEmpty_ShouldReturnEmptyList() + { + // Act + var history = await _service.GetHistoryAsync(); + + // Assert + history.Should().BeEmpty(); + } + + [Fact] + public async Task GetHistoryAsync_ShouldReturnNewestFirst() + { + // Arrange + var estimate1 = EstimateResult.Create("1 day", EstimateMode.Work, 0.3, TimeSpan.FromSeconds(3)); + await Task.Delay(10); // Ensure different timestamps + var estimate2 = EstimateResult.Create("2 days", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + await Task.Delay(10); + var estimate3 = EstimateResult.Create("3 days", EstimateMode.Work, 0.7, TimeSpan.FromSeconds(7)); + + // Act + await _service.SaveEstimateAsync(estimate1); + await _service.SaveEstimateAsync(estimate2); + await _service.SaveEstimateAsync(estimate3); + var history = await _service.GetHistoryAsync(); + + // Assert + history.Should().HaveCount(3); + history[0].EstimateText.Should().Be("3 days"); // Newest + history[1].EstimateText.Should().Be("2 days"); + history[2].EstimateText.Should().Be("1 day"); // Oldest + } + + [Fact] + public async Task GetHistoryAsync_WithCount_ShouldLimitResults() + { + // Arrange + for (int i = 0; i < 15; i++) + { + var estimate = EstimateResult.Create($"{i} days", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + await _service.SaveEstimateAsync(estimate); + await Task.Delay(5); // Ensure different timestamps + } + + // Act + var history = await _service.GetHistoryAsync(5); + + // Assert + history.Should().HaveCount(5); + } + + [Fact] + public async Task SaveEstimateAsync_ShouldAutoPruneWhenExceedingMaxSize() + { + // Arrange + var settings = new AppSettings { MaxHistorySize = 5 }; + await _service.SaveSettingsAsync(settings); + + // Act - Save 7 estimates (exceeds max of 5) + for (int i = 0; i < 7; i++) + { + var estimate = EstimateResult.Create($"{i} days", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + await _service.SaveEstimateAsync(estimate); + await Task.Delay(5); + } + + var count = await _service.GetHistoryCountAsync(); + + // Assert - Should have pruned to max size + count.Should().Be(5); + } + + [Fact] + public async Task SaveEstimateAsync_AfterPruning_ShouldKeepNewestEstimates() + { + // Arrange + var settings = new AppSettings { MaxHistorySize = 3 }; + await _service.SaveSettingsAsync(settings); + + // Act + for (int i = 0; i < 5; i++) + { + var estimate = EstimateResult.Create($"{i} days", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + await _service.SaveEstimateAsync(estimate); + await Task.Delay(10); + } + + var history = await _service.GetHistoryAsync(); + + // Assert - Should have only the newest 3 + history.Should().HaveCount(3); + history[0].EstimateText.Should().Be("4 days"); // Newest + history[1].EstimateText.Should().Be("3 days"); + history[2].EstimateText.Should().Be("2 days"); + // "0 days" and "1 days" should be pruned + } + + [Fact] + public async Task ClearHistoryAsync_ShouldRemoveAllEstimates() + { + // Arrange + for (int i = 0; i < 5; i++) + { + var estimate = EstimateResult.Create($"{i} days", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + await _service.SaveEstimateAsync(estimate); + } + + // Act + await _service.ClearHistoryAsync(); + var count = await _service.GetHistoryCountAsync(); + + // Assert + count.Should().Be(0); + } + + [Fact] + public async Task GetHistoryCountAsync_ShouldReturnCorrectCount() + { + // Arrange + for (int i = 0; i < 7; i++) + { + var estimate = EstimateResult.Create($"{i} days", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + await _service.SaveEstimateAsync(estimate); + } + + // Act + var count = await _service.GetHistoryCountAsync(); + + // Assert + count.Should().Be(7); + } + + #endregion + + #region Edge Cases + + [Fact] + public async Task SaveEstimateAsync_WithSpecialCharacters_ShouldPersist() + { + // Arrange + var estimate = EstimateResult.Create("when hell freezes over", EstimateMode.Humorous, 0.9, TimeSpan.FromSeconds(20)); + + // Act + await _service.SaveEstimateAsync(estimate); + var history = await _service.GetHistoryAsync(); + + // Assert + history.Should().ContainSingle(); + history[0].EstimateText.Should().Be("when hell freezes over"); + } + + [Fact] + public async Task GetHistoryAsync_WithCountZero_ShouldReturnEmptyList() + { + // Arrange + var estimate = EstimateResult.Create("1 day", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + await _service.SaveEstimateAsync(estimate); + + // Act + var history = await _service.GetHistoryAsync(0); + + // Assert + history.Should().BeEmpty(); + } + + [Fact] + public async Task GetHistoryAsync_WithNegativeCount_ShouldReturnEmptyList() + { + // Arrange + var estimate = EstimateResult.Create("1 day", EstimateMode.Work, 0.5, TimeSpan.FromSeconds(5)); + await _service.SaveEstimateAsync(estimate); + + // Act + var history = await _service.GetHistoryAsync(-1); + + // Assert + history.Should().BeEmpty(); + } + + #endregion +}