mirror of
https://github.com/ivuorinen/hiha-arvio.git
synced 2026-02-12 20:49:20 +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:
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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user