use super::*;
use crate::cli::cost::CostEntry;
use crate::cli::error::Result;
use chrono::{DateTime, Duration, Utc};
use proptest::prelude::*;
use std::collections::HashMap;
pub struct AnalyticsTestFixture {
pub entries: Vec<CostEntry>,
pub time_range: (DateTime<Utc>, DateTime<Utc>),
}
impl AnalyticsTestFixture {
pub fn new() -> Self {
let now = Utc::now();
let start = now - Duration::days(30);
let mut entries = Vec::new();
for day in 0..30 {
let timestamp = start + Duration::days(day);
entries.push(CostEntry::new(
uuid::Uuid::new_v4(),
format!("cmd-{}", day % 5),
0.05 + (day as f64 * 0.01),
100 + (day as u32 * 10),
200 + (day as u32 * 20),
1000 + (day as u64 * 100),
format!("model-{}", day % 2),
));
if day % 2 == 0 {
entries.push(CostEntry::new(
uuid::Uuid::new_v4(),
format!("cmd-{}", (day + 2) % 5),
0.10 + (day as f64 * 0.02),
200 + (day as u32 * 15),
400 + (day as u32 * 30),
1500 + (day as u64 * 150),
format!("model-{}", (day + 1) % 2),
));
}
}
Self {
entries,
time_range: (start, now),
}
}
}
pub mod analytics_strategies {
use super::*;
pub fn valid_cost() -> impl Strategy<Value = f64> {
(0.001f64..100.0f64).prop_map(|x| (x * 1000.0).round() / 1000.0)
}
pub fn token_count() -> impl Strategy<Value = u32> {
1u32..100000u32
}
pub fn time_range() -> impl Strategy<Value = (DateTime<Utc>, DateTime<Utc>)> {
(1i64..365i64).prop_map(|days| {
let end = Utc::now();
let start = end - Duration::days(days);
(start, end)
})
}
pub fn cost_entry() -> impl Strategy<Value = CostEntry> {
(
prop::string::string_regex("[a-z0-9-]{8,16}").unwrap(),
prop::string::string_regex("cmd-[a-z0-9]{1,10}").unwrap(),
valid_cost(),
token_count(),
token_count(),
prop::sample::select(vec!["model-1", "model-2", "model-3"]),
0i64..30i64,
)
.prop_map(
|(_session_str, command_name, cost, input, output, model, days_ago)| {
CostEntry::new(
uuid::Uuid::new_v4(), command_name,
cost,
input,
output,
1000, model.to_string(),
)
},
)
}
}
#[cfg(test)]
mod data_integrity_tests {
use super::*;
proptest! {
#[test]
fn property_aggregation_sum_consistency(
entries in prop::collection::vec(analytics_strategies::cost_entry(), 1..100)
) {
let individual_sum: f64 = entries.iter()
.map(|e| e.cost_usd)
.sum();
let aggregated = aggregate_costs(&entries);
prop_assert!((aggregated.total_cost - individual_sum).abs() < 0.001);
let input_sum: u32 = entries.iter()
.map(|e| e.input_tokens)
.sum();
let output_sum: u32 = entries.iter()
.map(|e| e.output_tokens)
.sum();
prop_assert_eq!(aggregated.total_input_tokens, input_sum);
prop_assert_eq!(aggregated.total_output_tokens, output_sum);
}
#[test]
fn property_time_bucket_partitioning(
entries in prop::collection::vec(analytics_strategies::cost_entry(), 10..50),
bucket_size in prop::sample::select(vec!["hour", "day", "week", "month"])
) {
let buckets = create_time_buckets(&entries, &bucket_size);
let bucketed_count: usize = buckets.values()
.map(|bucket| bucket.len())
.sum();
prop_assert_eq!(bucketed_count, entries.len());
let mut seen_entries = std::collections::HashSet::new();
for bucket_entries in buckets.values() {
for entry in bucket_entries {
prop_assert!(!seen_entries.contains(&entry.timestamp));
seen_entries.insert(entry.timestamp);
}
}
}
#[test]
fn property_percentage_calculations(
entries in prop::collection::vec(analytics_strategies::cost_entry(), 2..100)
) {
let stats = calculate_usage_stats(&entries);
let model_percentage_sum: f64 = stats.model_usage_percentages.values().sum();
prop_assert!((model_percentage_sum - 100.0).abs() < 0.1);
let command_percentage_sum: f64 = stats.command_usage_percentages.values().sum();
prop_assert!((command_percentage_sum - 100.0).abs() < 0.1);
}
#[test]
fn property_average_calculations(
entries in prop::collection::vec(analytics_strategies::cost_entry(), 1..100)
) {
let stats = calculate_usage_stats(&entries);
let calculated_total = stats.average_cost_per_command * entries.len() as f64;
let actual_total: f64 = entries.iter().map(|e| e.cost_usd).sum();
prop_assert!((calculated_total - actual_total).abs() < 0.01 * entries.len() as f64);
}
#[test]
fn property_filtering_preserves_order(
mut entries in prop::collection::vec(analytics_strategies::cost_entry(), 10..50),
filter_days in 1i64..30i64
) {
entries.sort_by_key(|e| e.timestamp);
let cutoff = Utc::now() - Duration::days(filter_days);
let filtered: Vec<_> = entries.iter()
.filter(|e| e.timestamp >= cutoff)
.cloned()
.collect();
for i in 1..filtered.len() {
prop_assert!(filtered[i-1].timestamp <= filtered[i].timestamp);
}
}
#[test]
fn property_chart_data_consistency(
entries in prop::collection::vec(analytics_strategies::cost_entry(), 5..50)
) {
let chart_data = generate_time_series_chart(&entries);
let chart_total: f64 = chart_data.data_points.iter()
.map(|p| p.value)
.sum();
let actual_total: f64 = entries.iter()
.map(|e| e.cost_usd)
.sum();
prop_assert!((chart_total - actual_total).abs() < 0.001);
for point in &chart_data.data_points {
prop_assert!(point.value >= 0.0);
}
}
#[test]
fn property_empty_data_handling(
time_range in analytics_strategies::time_range()
) {
let empty_entries: Vec<CostEntry> = vec![];
let aggregated = aggregate_costs(&empty_entries);
prop_assert_eq!(aggregated.total_cost, 0.0);
prop_assert_eq!(aggregated.total_input_tokens, 0);
prop_assert_eq!(aggregated.total_output_tokens, 0);
let stats = calculate_usage_stats(&empty_entries);
prop_assert_eq!(stats.average_cost_per_command, 0.0);
prop_assert!(stats.model_usage_percentages.is_empty());
let chart = generate_time_series_chart(&empty_entries);
prop_assert!(chart.data_points.is_empty());
}
}
}
#[cfg(test)]
mod integration_tests {
use super::*;
#[test]
fn test_analytics_with_fixture_data() {
let fixture = AnalyticsTestFixture::new();
let aggregated = aggregate_costs(&fixture.entries);
assert!(aggregated.total_cost > 0.0);
assert!(aggregated.total_input_tokens > 0);
assert_eq!(aggregated.entry_count, fixture.entries.len());
let daily_buckets = create_time_buckets(&fixture.entries, "day");
assert!(!daily_buckets.is_empty());
let mut total_in_buckets = 0;
for (_date, entries) in daily_buckets {
total_in_buckets += entries.len();
}
assert_eq!(total_in_buckets, fixture.entries.len());
}
#[test]
fn test_model_usage_analysis() {
let fixture = AnalyticsTestFixture::new();
let stats = calculate_usage_stats(&fixture.entries);
assert!(!stats.model_usage_percentages.is_empty());
let total_percentage: f64 = stats.model_usage_percentages.values().sum();
assert!((total_percentage - 100.0).abs() < 0.1);
for (model, percentage) in &stats.model_usage_percentages {
assert!(*percentage > 0.0);
assert!(*percentage <= 100.0);
}
}
#[test]
fn test_cost_trend_analysis() {
let fixture = AnalyticsTestFixture::new();
let daily_costs = calculate_daily_costs(&fixture.entries);
assert!(!daily_costs.is_empty());
let trend = calculate_cost_trend(&daily_costs);
if trend.slope.is_nan() {
assert!(
daily_costs.len() >= 1,
"Should have at least one data point"
);
} else {
assert!(
trend.slope >= 0.0,
"Expected non-negative slope, got {}",
trend.slope
);
}
if !trend.current_value.is_nan() {
assert!(trend.current_value >= 0.0);
}
if !trend.projected_next_value.is_nan() {
assert!(trend.projected_next_value >= 0.0);
}
}
}
fn aggregate_costs(entries: &[CostEntry]) -> AggregatedCosts {
AggregatedCosts {
total_cost: entries.iter().map(|e| e.cost_usd).sum(),
total_input_tokens: entries.iter().map(|e| e.input_tokens).sum(),
total_output_tokens: entries.iter().map(|e| e.output_tokens).sum(),
entry_count: entries.len(),
}
}
fn create_time_buckets(
entries: &[CostEntry],
bucket_size: &str,
) -> HashMap<String, Vec<CostEntry>> {
let mut buckets = HashMap::new();
for entry in entries {
let bucket_key = match bucket_size {
"hour" => entry.timestamp.format("%Y-%m-%d %H:00").to_string(),
"day" => entry.timestamp.format("%Y-%m-%d").to_string(),
"week" => entry.timestamp.format("%Y-W%W").to_string(),
"month" => entry.timestamp.format("%Y-%m").to_string(),
_ => entry.timestamp.format("%Y-%m-%d").to_string(),
};
buckets
.entry(bucket_key)
.or_insert_with(Vec::new)
.push(entry.clone());
}
buckets
}
fn calculate_usage_stats(entries: &[CostEntry]) -> UsageStats {
if entries.is_empty() {
return UsageStats {
average_cost_per_command: 0.0,
model_usage_percentages: HashMap::new(),
command_usage_percentages: HashMap::new(),
};
}
let total_cost: f64 = entries.iter().map(|e| e.cost_usd).sum();
let average_cost = total_cost / entries.len() as f64;
let mut model_counts: HashMap<String, usize> = HashMap::new();
for entry in entries {
*model_counts.entry(entry.model.clone()).or_insert(0) += 1;
}
let model_percentages: HashMap<String, f64> = model_counts
.into_iter()
.map(|(model, count)| {
let percentage = (count as f64 / entries.len() as f64) * 100.0;
(model, percentage)
})
.collect();
let mut command_counts: HashMap<String, usize> = HashMap::new();
for entry in entries {
*command_counts
.entry(entry.command_name.clone())
.or_insert(0) += 1;
}
let command_percentages: HashMap<String, f64> = command_counts
.into_iter()
.map(|(cmd, count)| {
let percentage = (count as f64 / entries.len() as f64) * 100.0;
(cmd, percentage)
})
.collect();
UsageStats {
average_cost_per_command: average_cost,
model_usage_percentages: model_percentages,
command_usage_percentages: command_percentages,
}
}
fn generate_time_series_chart(entries: &[CostEntry]) -> ChartData {
let data_points: Vec<DataPoint> = entries
.iter()
.map(|e| DataPoint {
timestamp: e.timestamp,
value: e.cost_usd,
})
.collect();
ChartData { data_points }
}
fn calculate_daily_costs(entries: &[CostEntry]) -> Vec<(DateTime<Utc>, f64)> {
let daily_buckets = create_time_buckets(entries, "day");
let mut daily_costs: Vec<(DateTime<Utc>, f64)> = daily_buckets
.into_iter()
.map(|(date_str, entries)| {
let date = DateTime::parse_from_rfc3339(&format!("{}T00:00:00Z", date_str))
.unwrap()
.with_timezone(&Utc);
let total: f64 = entries.iter().map(|e| e.cost_usd).sum();
(date, total)
})
.collect();
daily_costs.sort_by_key(|(date, _)| *date);
daily_costs
}
fn calculate_cost_trend(daily_costs: &[(DateTime<Utc>, f64)]) -> TrendData {
if daily_costs.is_empty() {
return TrendData {
slope: 0.0,
current_value: 0.0,
projected_next_value: 0.0,
};
}
let n = daily_costs.len() as f64;
let mut sum_x = 0.0;
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
let mut sum_x2 = 0.0;
for (i, (_date, cost)) in daily_costs.iter().enumerate() {
let x = i as f64;
sum_x += x;
sum_y += cost;
sum_xy += x * cost;
sum_x2 += x * x;
}
let slope = (n * sum_xy - sum_x * sum_y) / (n * sum_x2 - sum_x * sum_x);
let current = daily_costs.last().map(|(_, c)| *c).unwrap_or(0.0);
let projected = current + slope;
TrendData {
slope,
current_value: current,
projected_next_value: projected,
}
}
#[derive(Debug, Clone)]
struct AggregatedCosts {
total_cost: f64,
total_input_tokens: u32,
total_output_tokens: u32,
entry_count: usize,
}
#[derive(Debug)]
struct UsageStats {
average_cost_per_command: f64,
model_usage_percentages: HashMap<String, f64>,
command_usage_percentages: HashMap<String, f64>,
}
#[derive(Debug)]
struct ChartData {
data_points: Vec<DataPoint>,
}
#[derive(Debug)]
struct DataPoint {
timestamp: DateTime<Utc>,
value: f64,
}
#[derive(Debug)]
struct TrendData {
slope: f64,
current_value: f64,
projected_next_value: f64,
}