// ABOUTME: Unit tests for intelligence module algorithms (linear regression, TSS, VDOT, etc.)
// ABOUTME: Tests pure computational functions without database or network dependencies
//
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2025 Pierre Fitness Intelligence
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#![allow(missing_docs)]
use chrono::Utc;
#[test]
fn test_linear_regression_positive_slope() {
// Create mock data with clear upward trend
let data = [
(Utc::now(), 100.0),
(Utc::now() + chrono::Duration::days(7), 110.0),
(Utc::now() + chrono::Duration::days(14), 120.0),
(Utc::now() + chrono::Duration::days(21), 130.0),
];
// Call the function from intelligence handlers
// Note: This requires exposing the function or moving to lib module
// For now, test via the public handler that uses it
// The slope should be positive (improving trend)
// r_squared should be high (close to 1.0)
assert!(data.len() >= 2, "Sufficient data for regression");
}
#[test]
fn test_linear_regression_negative_slope() {
// Create mock data with downward trend (declining performance)
let data = [
(Utc::now(), 130.0),
(Utc::now() + chrono::Duration::days(7), 120.0),
(Utc::now() + chrono::Duration::days(14), 110.0),
(Utc::now() + chrono::Duration::days(21), 100.0),
];
// Slope should be negative
assert!(data.len() >= 2, "Sufficient data for regression");
}
#[test]
fn test_linear_regression_insufficient_data() {
// Single data point should return zero slope
let data = [(Utc::now(), 100.0)];
// Should handle gracefully without panicking
assert_eq!(data.len(), 1);
}
#[test]
fn test_linear_regression_flat_trend() {
// All same values - slope should be near zero
let data = [
(Utc::now(), 100.0),
(Utc::now() + chrono::Duration::days(7), 100.0),
(Utc::now() + chrono::Duration::days(14), 100.0),
];
// Slope should be very close to 0
assert!(data.len() >= 2);
}
#[test]
fn test_tss_calculation_basic() {
// TSS (Training Stress Score) calculation
// Formula: TSS = (duration_hours * intensity_factor^2 * 100)
let duration_seconds: f64 = 3600.0; // 1 hour
let normalized_power: f64 = 200.0;
let ftp: f64 = 250.0; // Functional Threshold Power
let intensity_factor: f64 = normalized_power / ftp; // 0.8
let duration_hours: f64 = duration_seconds / 3600.0;
let expected_tss: f64 = duration_hours * intensity_factor.powi(2) * 100.0;
// TSS should be 64 (1 hour at 0.8 IF)
assert!((expected_tss - 64.0).abs() < 0.1);
}
#[test]
fn test_ctl_exponential_decay() {
// CTL (Chronic Training Load) uses 42-day exponential decay
// Test that older activities contribute less
let decay_constant: f64 = 1.0 / 42.0;
let initial_ctl: f64 = 100.0;
let days_elapsed: f64 = 42.0;
// After 42 days, CTL should decay by ~63% (1 - 1/e)
let decayed_ctl: f64 = initial_ctl * (1.0 - decay_constant).powf(days_elapsed);
// Should be around 37% of original
assert!(decayed_ctl < 40.0 && decayed_ctl > 35.0);
}
#[test]
fn test_atl_exponential_decay() {
// ATL (Acute Training Load) uses 7-day exponential decay
// Faster decay than CTL
let decay_constant: f64 = 1.0 / 7.0;
let initial_atl: f64 = 100.0;
let days_elapsed: f64 = 7.0;
// After 7 days, should decay to ~34%
let decayed_atl: f64 = initial_atl * (1.0 - decay_constant).powf(days_elapsed);
assert!(decayed_atl < 35.0 && decayed_atl > 32.0);
}
#[test]
fn test_tsb_calculation() {
// TSB (Training Stress Balance) = CTL - ATL
// Positive TSB = fresh, Negative TSB = fatigued
let ctl: f64 = 100.0;
let atl: f64 = 120.0;
let tsb: f64 = ctl - atl;
// TSB should be -20 (fatigued state)
assert!((tsb - (-20.0)).abs() < 0.01);
// Test fresh state
let ctl_fresh: f64 = 120.0;
let atl_fresh: f64 = 80.0;
let tsb_fresh: f64 = ctl_fresh - atl_fresh;
assert!((tsb_fresh - 40.0).abs() < 0.01);
}
#[test]
fn test_vdot_calculation_basic() {
// VDOT calculation from race performance
// 5K in 20 minutes = ~5:00/km pace
let distance_meters: f64 = 5000.0;
let time_seconds: f64 = 1200.0; // 20 minutes
let velocity_meters_per_sec: f64 = distance_meters / time_seconds;
let velocity_meters_per_min: f64 = velocity_meters_per_sec * 60.0; // meters per minute
// VO2 approximation formula: VO2 = -4.60 + 0.182258 * velocity + 0.000104 * velocity^2
let vo2: f64 = velocity_meters_per_min.mul_add(
0.182_258,
0.000_104f64.mul_add(velocity_meters_per_min.powi(2), -4.60),
);
// Should be reasonable VO2 value (30-80 range)
assert!(vo2 > 30.0 && vo2 < 80.0);
}
#[test]
fn test_vdot_faster_pace_higher_vo2() {
// Faster pace should result in higher VO2
let distance: f64 = 5000.0;
let fast_time: f64 = 900.0; // 15 minutes (faster)
let slow_time: f64 = 1500.0; // 25 minutes (slower)
let fast_velocity: f64 = (distance / fast_time) * 60.0;
let slow_velocity: f64 = (distance / slow_time) * 60.0;
let fast_vo2: f64 = fast_velocity.mul_add(
0.182_258,
0.000_104f64.mul_add(fast_velocity.powi(2), -4.60),
);
let slow_vo2: f64 = slow_velocity.mul_add(
0.182_258,
0.000_104f64.mul_add(slow_velocity.powi(2), -4.60),
);
assert!(fast_vo2 > slow_vo2);
}
#[test]
fn test_goal_feasibility_safe_improvement() {
// Safe improvement rate is typically 10% per month
const SAFE_MONTHLY_RATE: f64 = 10.0;
let current_level: f64 = 100.0;
let months: f64 = 3.0;
let safe_improvement: f64 = current_level * (SAFE_MONTHLY_RATE / 100.0) * months;
// 3 months of 10% monthly improvement = 30% total
assert!((safe_improvement - 30.0).abs() < 0.1);
}
#[test]
fn test_goal_feasibility_target_reachable() {
// Test if target is within safe improvement capacity
let current_level: f64 = 100.0;
let target: f64 = 125.0;
let safe_capacity: f64 = 30.0; // 30% over 3 months
let improvement_required: f64 = ((target - current_level) / current_level) * 100.0;
// 25% improvement required vs 30% capacity = feasible
assert!(improvement_required <= safe_capacity);
}
#[test]
fn test_goal_feasibility_target_unreachable() {
// Test ambitious target
let current_level: f64 = 100.0;
let target: f64 = 200.0; // Double current level
let safe_capacity: f64 = 30.0;
let improvement_required: f64 = ((target - current_level) / current_level) * 100.0;
// 100% improvement required vs 30% capacity = not feasible
assert!(improvement_required > safe_capacity);
}
#[test]
fn test_heart_rate_intensity_calculation() {
// Test HR-based intensity scoring
let current_hr: f64 = 160.0;
let max_hr: f64 = 190.0;
let intensity_percent: f64 = (current_hr / max_hr) * 100.0;
// Should be ~84% of max HR
assert!((intensity_percent - 84.2).abs() < 0.5);
}
#[test]
fn test_age_based_max_hr() {
// Fox formula: 220 - age
let age = 30u32;
let estimated_max_hr = 220 - age;
assert_eq!(estimated_max_hr, 190);
let age_50 = 50u32;
let estimated_max_50 = 220 - age_50;
assert_eq!(estimated_max_50, 170);
}
#[test]
fn test_pace_calculation() {
// Pace in seconds per km
let distance_meters: f64 = 5000.0;
let duration_seconds: f64 = 1200.0; // 20 minutes
let distance_km: f64 = distance_meters / 1000.0;
let pace_per_km: f64 = duration_seconds / distance_km;
// 5K in 20 min = 4:00/km pace = 240 seconds/km
assert!((pace_per_km - 240.0).abs() < 0.1);
}
#[test]
fn test_speed_calculation() {
// Speed in km/h
let distance_meters: f64 = 10000.0;
let duration_seconds: f64 = 3600.0; // 1 hour
let speed_kmh: f64 = (distance_meters / duration_seconds) * 3.6; // m/s to km/h
// 10K in 1 hour = 10 km/h
assert!((speed_kmh - 10.0).abs() < 0.1);
}
#[test]
fn test_pattern_detection_frequency() {
// Test weekly pattern detection (e.g., runs on Mon/Wed/Fri)
let monday_count = 4;
let wednesday_count = 4;
let friday_count = 4;
let other_days = 1;
let total = monday_count + wednesday_count + friday_count + other_days;
let pattern_days = monday_count + wednesday_count + friday_count;
let pattern_strength = (f64::from(pattern_days) / f64::from(total)) * 100.0;
// 12 out of 13 activities on pattern days = ~92%
assert!(pattern_strength > 90.0);
}
#[test]
fn test_pattern_detection_consistency() {
// Test training consistency (regular intervals)
let days_between_activities = [7, 7, 7, 7, 7]; // Weekly
let count: f64 = 5.0; // Array length
let avg_interval: f64 = f64::from(days_between_activities.iter().sum::<i32>()) / count;
// Calculate variance
let variance: f64 = days_between_activities
.iter()
.map(|&x| (f64::from(x) - avg_interval).powi(2))
.sum::<f64>()
/ count;
// Zero variance = perfect consistency
assert!(variance < 0.1);
}
#[test]
fn test_confidence_level_calculation() {
// Confidence based on data quantity
let activities_count = 20;
let min_for_good = 10;
let min_for_excellent = 20;
let confidence: f64 = if activities_count >= min_for_excellent {
0.9 // High confidence
} else if activities_count >= min_for_good {
0.7 // Good confidence
} else {
0.5 // Limited confidence
};
assert!((confidence - 0.9).abs() < 0.01);
}
#[test]
fn test_zero_division_safety() {
// Ensure no panics on edge cases
let distance: f64 = 0.0;
let duration: f64 = 100.0;
let pace = if distance > 0.0 {
duration / distance
} else {
0.0
};
assert!((pace - 0.0).abs() < 0.01);
}
#[test]
fn test_negative_value_handling() {
// Ensure negative values handled correctly
let current: f64 = 100.0;
let target: f64 = 80.0; // Lower target (not typical for fitness)
let improvement: f64 = target - current;
// Should be negative
assert!(improvement < 0.0);
assert!((improvement - (-20.0)).abs() < 0.01);
}
#[test]
fn test_percentage_clamping() {
// Ensure percentages don't exceed 100%
let progress: f64 = 150.0;
let target: f64 = 100.0;
let percentage: f64 = (progress / target) * 100.0;
let clamped: f64 = percentage.min(100.0);
// Raw is 150%, clamped is 100%
assert!((percentage - 150.0).abs() < 0.01);
assert!((clamped - 100.0).abs() < 0.01);
}