mirror of
https://github.com/ivuorinen/hiha-arvio.git
synced 2026-03-13 15:00:48 +00:00
feat: implement services layer with TDD (Milestone 2)
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 <noreply@anthropic.com>
This commit is contained in:
54
CLAUDE.md
54
CLAUDE.md
@@ -197,6 +197,60 @@ HihaArvio.sln
|
|||||||
- Code analysis: StyleCop + built-in analyzers enabled
|
- Code analysis: StyleCop + built-in analyzers enabled
|
||||||
- CI/CD must enforce 95% coverage threshold and fail builds below this
|
- 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
|
## Important Implementation Order
|
||||||
|
|
||||||
Per design document, phased development:
|
Per design document, phased development:
|
||||||
|
|||||||
86
src/HihaArvio/Services/EstimateService.cs
Normal file
86
src/HihaArvio/Services/EstimateService.cs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using HihaArvio.Models;
|
||||||
|
using HihaArvio.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace HihaArvio.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for generating time estimates based on shake data.
|
||||||
|
/// Implements intensity-based range selection and easter egg logic.
|
||||||
|
/// </summary>
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selects a random item from the first N items of the array using cryptographically secure RNG.
|
||||||
|
/// </summary>
|
||||||
|
private static string SelectRandomFromRange(string[] array, int rangeSize)
|
||||||
|
{
|
||||||
|
var index = RandomNumberGenerator.GetInt32(0, rangeSize);
|
||||||
|
return array[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/HihaArvio/Services/Interfaces/IEstimateService.cs
Normal file
25
src/HihaArvio/Services/Interfaces/IEstimateService.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using HihaArvio.Models;
|
||||||
|
|
||||||
|
namespace HihaArvio.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for generating time estimates based on shake data.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEstimateService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a time estimate based on shake intensity, duration, and selected mode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="intensity">Normalized shake intensity (0.0 to 1.0).</param>
|
||||||
|
/// <param name="duration">Duration of the shake gesture.</param>
|
||||||
|
/// <param name="mode">The estimation mode (Work, Generic, or Humorous).</param>
|
||||||
|
/// <returns>An EstimateResult with the generated estimate and metadata.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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)
|
||||||
|
/// </remarks>
|
||||||
|
EstimateResult GenerateEstimate(double intensity, TimeSpan duration, EstimateMode mode);
|
||||||
|
}
|
||||||
48
src/HihaArvio/Services/Interfaces/IShakeDetectionService.cs
Normal file
48
src/HihaArvio/Services/Interfaces/IShakeDetectionService.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using HihaArvio.Models;
|
||||||
|
|
||||||
|
namespace HihaArvio.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for detecting shake gestures from accelerometer data.
|
||||||
|
/// Implements shake detection algorithm with intensity calculation.
|
||||||
|
/// </summary>
|
||||||
|
public interface IShakeDetectionService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current shake data (intensity, duration, isShaking status).
|
||||||
|
/// </summary>
|
||||||
|
ShakeData CurrentShakeData { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts monitoring for shake gestures.
|
||||||
|
/// </summary>
|
||||||
|
void StartMonitoring();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops monitoring for shake gestures.
|
||||||
|
/// </summary>
|
||||||
|
void StopMonitoring();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether monitoring is currently active.
|
||||||
|
/// </summary>
|
||||||
|
bool IsMonitoring { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes accelerometer reading and updates shake detection state.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="x">X-axis acceleration in g's.</param>
|
||||||
|
/// <param name="y">Y-axis acceleration in g's.</param>
|
||||||
|
/// <param name="z">Z-axis acceleration in g's.</param>
|
||||||
|
void ProcessAccelerometerReading(double x, double y, double z);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resets the shake detection state (useful after generating an estimate).
|
||||||
|
/// </summary>
|
||||||
|
void Reset();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event raised when shake data changes (intensity, duration, or isShaking status).
|
||||||
|
/// </summary>
|
||||||
|
event EventHandler<ShakeData>? ShakeDataChanged;
|
||||||
|
}
|
||||||
46
src/HihaArvio/Services/Interfaces/IStorageService.cs
Normal file
46
src/HihaArvio/Services/Interfaces/IStorageService.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
using HihaArvio.Models;
|
||||||
|
|
||||||
|
namespace HihaArvio.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for persisting application settings and estimate history.
|
||||||
|
/// </summary>
|
||||||
|
public interface IStorageService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Saves application settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="settings">The settings to save.</param>
|
||||||
|
Task SaveSettingsAsync(AppSettings settings);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads application settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The loaded settings, or default settings if none exist.</returns>
|
||||||
|
Task<AppSettings> LoadSettingsAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves an estimate result to history.
|
||||||
|
/// Automatically prunes history if it exceeds the maximum size.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="estimate">The estimate to save.</param>
|
||||||
|
Task SaveEstimateAsync(EstimateResult estimate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets estimate history, ordered by timestamp (newest first).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="count">Maximum number of estimates to retrieve (default 10).</param>
|
||||||
|
/// <returns>List of estimate results, newest first.</returns>
|
||||||
|
Task<List<EstimateResult>> GetHistoryAsync(int count = 10);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears all estimate history.
|
||||||
|
/// </summary>
|
||||||
|
Task ClearHistoryAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current count of estimates in history.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The number of estimates in storage.</returns>
|
||||||
|
Task<int> GetHistoryCountAsync();
|
||||||
|
}
|
||||||
136
src/HihaArvio/Services/ShakeDetectionService.cs
Normal file
136
src/HihaArvio/Services/ShakeDetectionService.cs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
using HihaArvio.Models;
|
||||||
|
using HihaArvio.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace HihaArvio.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for detecting shake gestures from accelerometer data.
|
||||||
|
/// Implements shake detection algorithm per spec with 1.5g threshold and 4g max intensity.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ShakeData CurrentShakeData => _currentShakeData;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsMonitoring => _isMonitoring;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public event EventHandler<ShakeData>? ShakeDataChanged;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void StartMonitoring()
|
||||||
|
{
|
||||||
|
_isMonitoring = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void StopMonitoring()
|
||||||
|
{
|
||||||
|
_isMonitoring = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_currentShakeData = new ShakeData
|
||||||
|
{
|
||||||
|
IsShaking = false,
|
||||||
|
Intensity = 0.0,
|
||||||
|
Duration = TimeSpan.Zero
|
||||||
|
};
|
||||||
|
_wasShakingLastUpdate = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
src/HihaArvio/Services/StorageService.cs
Normal file
175
src/HihaArvio/Services/StorageService.cs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
using HihaArvio.Models;
|
||||||
|
using HihaArvio.Services.Interfaces;
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace HihaArvio.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQLite-based storage service for persisting application settings and estimate history.
|
||||||
|
/// </summary>
|
||||||
|
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<SettingsEntity>();
|
||||||
|
await _database.CreateTableAsync<EstimateHistoryEntity>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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<SettingsEntity>().FirstOrDefaultAsync(s => s.Id == 1);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
await _database.UpdateAsync(entity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _database.InsertAsync(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<AppSettings> LoadSettingsAsync()
|
||||||
|
{
|
||||||
|
var entity = await _database.Table<SettingsEntity>().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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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<EstimateHistoryEntity>()
|
||||||
|
.OrderBy(e => e.Timestamp)
|
||||||
|
.Take(excessCount)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var old in oldestEstimates)
|
||||||
|
{
|
||||||
|
await _database.DeleteAsync(old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<List<EstimateResult>> GetHistoryAsync(int count = 10)
|
||||||
|
{
|
||||||
|
if (count <= 0)
|
||||||
|
{
|
||||||
|
return new List<EstimateResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var entities = await _database.Table<EstimateHistoryEntity>()
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task ClearHistoryAsync()
|
||||||
|
{
|
||||||
|
await _database.DeleteAllAsync<EstimateHistoryEntity>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<int> GetHistoryCountAsync()
|
||||||
|
{
|
||||||
|
return await _database.Table<EstimateHistoryEntity>().CountAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal entity for storing settings in SQLite.
|
||||||
|
/// </summary>
|
||||||
|
[Table("Settings")]
|
||||||
|
private class SettingsEntity
|
||||||
|
{
|
||||||
|
[PrimaryKey]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public EstimateMode SelectedMode { get; set; }
|
||||||
|
|
||||||
|
public int MaxHistorySize { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal entity for storing estimate history in SQLite.
|
||||||
|
/// </summary>
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
321
tests/HihaArvio.Tests/Integration/ServiceIntegrationTests.cs
Normal file
321
tests/HihaArvio.Tests/Integration/ServiceIntegrationTests.cs
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using HihaArvio.Models;
|
||||||
|
using HihaArvio.Services;
|
||||||
|
using HihaArvio.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace HihaArvio.Tests.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests verifying that services work together correctly.
|
||||||
|
/// </summary>
|
||||||
|
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<EstimateResult>();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
314
tests/HihaArvio.Tests/Services/EstimateServiceTests.cs
Normal file
314
tests/HihaArvio.Tests/Services/EstimateServiceTests.cs
Normal file
@@ -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
|
||||||
|
}
|
||||||
373
tests/HihaArvio.Tests/Services/ShakeDetectionServiceTests.cs
Normal file
373
tests/HihaArvio.Tests/Services/ShakeDetectionServiceTests.cs
Normal file
@@ -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<double>();
|
||||||
|
_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
|
||||||
|
}
|
||||||
283
tests/HihaArvio.Tests/Services/StorageServiceTests.cs
Normal file
283
tests/HihaArvio.Tests/Services/StorageServiceTests.cs
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user