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