use super::{CostEntry, CostFilter, CostTracker};
use crate::cli::cost::tracker::{
AdvancedCostTracker, Budget, BudgetScope, CostAlert, TrendDirection,
};
use crate::cli::session::SessionId;
use chrono::{DateTime, Duration, Utc};
use proptest::prelude::*;
use tempfile::tempdir;
use uuid::Uuid;
struct TrendTestFixture {
pub advanced_tracker: AdvancedCostTracker,
pub session_id: SessionId,
pub temp_dir: tempfile::TempDir,
}
impl TrendTestFixture {
async fn new() -> Self {
let temp_dir = tempdir().expect("Failed to create temp directory");
let storage_path = temp_dir.path().join("trend_test_costs.json");
let session_id = Uuid::new_v4();
let base_tracker = CostTracker::new(storage_path).unwrap();
let advanced_tracker = AdvancedCostTracker::new(base_tracker);
Self {
advanced_tracker,
session_id,
temp_dir,
}
}
async fn add_increasing_trend_data(&mut self, days: u32, base_cost: f64) {
let now = Utc::now();
for day in 0..days {
let timestamp = now - Duration::days((days - 1 - day) as i64);
let daily_cost = base_cost + (day as f64 * 0.5);
let mut entry = CostEntry::new(
self.session_id,
format!("trend_cmd_{}", day),
daily_cost,
100 + day * 10,
200 + day * 20,
1000 + day as u64 * 100,
"claude-3-opus".to_string(),
);
entry.timestamp = timestamp;
self.advanced_tracker.record_cost(entry).await.unwrap();
}
}
async fn add_decreasing_trend_data(&mut self, days: u32, base_cost: f64) {
let now = Utc::now();
for day in 0..days {
let timestamp = now - Duration::days((days - 1 - day) as i64);
let daily_cost = base_cost - (day as f64 * 0.3);
let mut entry = CostEntry::new(
self.session_id,
format!("decline_cmd_{}", day),
daily_cost.max(0.01), 150 + day * 5,
250 + day * 10,
1500 + day as u64 * 50,
"claude-3-sonnet".to_string(),
);
entry.timestamp = timestamp;
self.advanced_tracker.record_cost(entry).await.unwrap();
}
}
async fn add_stable_trend_data(&mut self, days: u32, base_cost: f64) {
let now = Utc::now();
for day in 0..days {
let timestamp = now - Duration::days((days - 1 - day) as i64);
let variation = ((day % 3) as f64 - 1.0) * 0.01; let daily_cost = base_cost + variation;
let mut entry = CostEntry::new(
self.session_id,
format!("stable_cmd_{}", day),
daily_cost,
80 + day * 2,
160 + day * 4,
800 + day as u64 * 25,
"claude-3-haiku".to_string(),
);
entry.timestamp = timestamp;
self.advanced_tracker.record_cost(entry).await.unwrap();
}
}
async fn add_optimization_scenario_data(&mut self) {
for i in 0..15 {
let entry = CostEntry::new(
self.session_id,
"small_operation".to_string(),
0.005, 10,
20,
50,
"claude-3-haiku".to_string(),
);
self.advanced_tracker.record_cost(entry).await.unwrap();
}
let expensive_entry = CostEntry::new(
self.session_id,
"expensive_analysis".to_string(),
2.50, 1000,
2000,
10000,
"claude-3-opus".to_string(),
);
self.advanced_tracker
.record_cost(expensive_entry)
.await
.unwrap();
for i in 0..5 {
let entry = CostEntry::new(
self.session_id,
"repeated_generation".to_string(),
0.25,
200,
400,
2000,
"claude-3-sonnet".to_string(),
);
self.advanced_tracker.record_cost(entry).await.unwrap();
}
}
}
#[tokio::test]
async fn test_trend_analysis_increasing_costs() {
let mut fixture = TrendTestFixture::new().await;
fixture.add_increasing_trend_data(14, 1.0).await;
let trend = fixture
.advanced_tracker
.analyze_cost_trend(14)
.await
.unwrap();
assert_eq!(trend.period_days, 14);
assert!(matches!(trend.trend_direction, TrendDirection::Increasing));
assert!(
trend.growth_rate > 0.0,
"Growth rate should be positive for increasing trend"
);
assert!(
trend.projected_monthly_cost > 30.0,
"Should project higher monthly cost"
);
assert_eq!(trend.daily_costs.len(), 14);
for i in 1..trend.daily_costs.len() {
assert!(
trend.daily_costs[i].0 > trend.daily_costs[i - 1].0,
"Daily costs should be in chronological order"
);
}
}
#[tokio::test]
async fn test_trend_analysis_decreasing_costs() {
let mut fixture = TrendTestFixture::new().await;
fixture.add_decreasing_trend_data(10, 2.0).await;
let trend = fixture
.advanced_tracker
.analyze_cost_trend(10)
.await
.unwrap();
assert_eq!(trend.period_days, 10);
assert!(matches!(trend.trend_direction, TrendDirection::Decreasing));
assert!(
trend.growth_rate < 0.0,
"Growth rate should be negative for decreasing trend"
);
assert!(
trend.projected_monthly_cost >= 0.0,
"Projected cost should not be negative"
);
assert_eq!(trend.daily_costs.len(), 10);
}
#[tokio::test]
async fn test_trend_analysis_stable_costs() {
let mut fixture = TrendTestFixture::new().await;
fixture.add_stable_trend_data(7, 1.5).await;
let trend = fixture
.advanced_tracker
.analyze_cost_trend(7)
.await
.unwrap();
assert_eq!(trend.period_days, 7);
assert!(matches!(trend.trend_direction, TrendDirection::Stable));
assert!(
trend.growth_rate.abs() < 5.0,
"Growth rate should be small for stable trend"
);
assert!(trend.projected_monthly_cost > 0.0);
assert_eq!(trend.daily_costs.len(), 7);
}
#[tokio::test]
async fn test_trend_analysis_with_filter() {
let mut fixture = TrendTestFixture::new().await;
fixture.add_increasing_trend_data(7, 0.5).await;
fixture.add_stable_trend_data(7, 1.0).await;
let filter = CostFilter {
command_pattern: Some("trend_cmd".to_string()),
..Default::default()
};
let trend = fixture
.advanced_tracker
.calculate_cost_trend(7, &filter)
.await
.unwrap();
assert_eq!(trend.period_days, 7);
assert!(matches!(trend.trend_direction, TrendDirection::Increasing));
}
#[tokio::test]
async fn test_optimization_recommendations() {
let mut fixture = TrendTestFixture::new().await;
fixture.add_optimization_scenario_data().await;
let recommendations = fixture
.advanced_tracker
.get_optimization_recommendations()
.await
.unwrap();
assert!(
!recommendations.is_empty(),
"Should generate optimization recommendations"
);
let has_batching_rec = recommendations
.iter()
.any(|r| r.to_lowercase().contains("batch") || r.to_lowercase().contains("small"));
assert!(
has_batching_rec,
"Should recommend batching small operations"
);
let has_expensive_rec = recommendations
.iter()
.any(|r| r.contains("expensive_analysis") || r.contains("30"));
assert!(
has_expensive_rec,
"Should identify expensive command pattern"
);
let has_budget_rec = recommendations
.iter()
.any(|r| r.to_lowercase().contains("budget"));
assert!(has_budget_rec, "Should recommend setting up budgets");
}
#[tokio::test]
async fn test_detailed_report_generation() {
let mut fixture = TrendTestFixture::new().await;
fixture.add_increasing_trend_data(5, 0.8).await;
fixture.add_optimization_scenario_data().await;
let report_path = fixture.temp_dir.path().join("detailed_cost_report.md");
fixture
.advanced_tracker
.export_detailed_report(&report_path)
.await
.unwrap();
assert!(report_path.exists(), "Report file should be created");
let content = tokio::fs::read_to_string(&report_path).await.unwrap();
assert!(
content.contains("# Claude AI Interactive - Cost Report"),
"Should have report title"
);
assert!(
content.contains("## Global Summary"),
"Should have global summary section"
);
assert!(content.contains("Total Cost:"), "Should include total cost");
assert!(
content.contains("Total Commands:"),
"Should include command count"
);
assert!(
content.contains("## Top Commands by Cost"),
"Should have top commands section"
);
assert!(
content.contains("## Cost by Model"),
"Should have model breakdown"
);
assert!(
content.contains("## Optimization Recommendations"),
"Should have recommendations"
);
assert!(
content.contains("expensive_analysis"),
"Should include expensive command"
);
assert!(
content.contains("claude-3-opus"),
"Should include model information"
);
}
#[tokio::test]
async fn test_report_generation_alias() {
let mut fixture = TrendTestFixture::new().await;
fixture.add_stable_trend_data(3, 0.5).await;
let report_path = fixture.temp_dir.path().join("alias_test_report.md");
fixture
.advanced_tracker
.generate_report(&report_path)
.await
.unwrap();
assert!(
report_path.exists(),
"Report should be generated via alias method"
);
let content = tokio::fs::read_to_string(&report_path).await.unwrap();
assert!(
content.contains("# Claude AI Interactive - Cost Report"),
"Should have same format as detailed report"
);
}
#[tokio::test]
async fn test_trend_projection_accuracy() {
let mut fixture = TrendTestFixture::new().await;
for day in 0..5 {
let cost = 1.0 + (day as f64 * 0.50); let timestamp = Utc::now() - Duration::days((4 - day) as i64);
let mut entry = CostEntry::new(
fixture.session_id,
format!("predictable_cmd_{}", day),
cost,
100,
200,
1000,
"claude-3-opus".to_string(),
);
entry.timestamp = timestamp;
fixture.advanced_tracker.record_cost(entry).await.unwrap();
}
let trend = fixture
.advanced_tracker
.analyze_cost_trend(5)
.await
.unwrap();
assert!(matches!(trend.trend_direction, TrendDirection::Increasing));
assert!(
trend.growth_rate > 10.0,
"Should detect significant growth rate"
);
let total_recent: f64 = trend.daily_costs.iter().map(|(_, cost)| cost).sum();
let avg_recent = total_recent / trend.daily_costs.len() as f64;
assert!(
avg_recent > 1.5 && avg_recent < 2.5,
"Average should be in expected range"
);
assert!(
trend.projected_monthly_cost > 50.0,
"Should project higher monthly cost due to growth"
);
}
#[tokio::test]
async fn test_empty_data_trend_analysis() {
let fixture = TrendTestFixture::new().await;
let trend = fixture
.advanced_tracker
.analyze_cost_trend(7)
.await
.unwrap();
assert_eq!(trend.period_days, 7);
assert!(matches!(trend.trend_direction, TrendDirection::Stable));
assert_eq!(trend.growth_rate, 0.0);
assert_eq!(trend.projected_monthly_cost, 0.0);
assert_eq!(trend.daily_costs.len(), 7);
for (_, cost) in trend.daily_costs {
assert_eq!(cost, 0.0);
}
}
#[tokio::test]
async fn test_optimization_recommendations_edge_cases() {
let mut fixture = TrendTestFixture::new().await;
let entry = CostEntry::new(
fixture.session_id,
"single_cmd".to_string(),
0.50,
100,
200,
1000,
"claude-3-opus".to_string(),
);
fixture.advanced_tracker.record_cost(entry).await.unwrap();
let recommendations = fixture
.advanced_tracker
.get_optimization_recommendations()
.await
.unwrap();
assert!(
!recommendations.is_empty(),
"Should generate recommendations even with minimal data"
);
let has_budget_rec = recommendations
.iter()
.any(|r| r.to_lowercase().contains("budget"));
assert!(
has_budget_rec,
"Should recommend budget setup for new usage"
);
}
#[tokio::test]
async fn test_report_generation_with_empty_data() {
let fixture = TrendTestFixture::new().await;
let report_path = fixture.temp_dir.path().join("empty_data_report.md");
fixture
.advanced_tracker
.export_detailed_report(&report_path)
.await
.unwrap();
assert!(
report_path.exists(),
"Should create report even with no data"
);
let content = tokio::fs::read_to_string(&report_path).await.unwrap();
assert!(content.contains("# Claude AI Interactive - Cost Report"));
assert!(content.contains("Total Cost: $0.0000"));
assert!(content.contains("Total Commands: 0"));
assert!(
content.contains("budget"),
"Should recommend setting up budgets"
);
}
#[tokio::test]
async fn test_trend_calculation_mathematical_accuracy() {
let mut fixture = TrendTestFixture::new().await;
for day in 0..5 {
let cost = (day + 1) as f64;
let timestamp = Utc::now() - Duration::days((4 - day) as i64);
let mut entry = CostEntry::new(
fixture.session_id,
format!("math_cmd_{}", day),
cost,
100,
200,
1000,
"claude-3-opus".to_string(),
);
entry.timestamp = timestamp;
fixture.advanced_tracker.record_cost(entry).await.unwrap();
}
let trend = fixture
.advanced_tracker
.analyze_cost_trend(5)
.await
.unwrap();
assert!(matches!(trend.trend_direction, TrendDirection::Increasing));
assert!(
trend.growth_rate > 20.0,
"Should detect strong linear growth"
);
let total_cost: f64 = trend.daily_costs.iter().map(|(_, cost)| cost).sum();
assert!(
(total_cost - 15.0).abs() < 0.01,
"Total cost should equal sum of progression"
);
}
#[tokio::test]
async fn test_multi_period_trend_analysis() {
let mut fixture = TrendTestFixture::new().await;
fixture.add_increasing_trend_data(10, 0.5).await;
fixture.add_stable_trend_data(10, 1.5).await;
fixture.add_decreasing_trend_data(10, 2.0).await;
let trend_7 = fixture
.advanced_tracker
.analyze_cost_trend(7)
.await
.unwrap();
let trend_14 = fixture
.advanced_tracker
.analyze_cost_trend(14)
.await
.unwrap();
let trend_30 = fixture
.advanced_tracker
.analyze_cost_trend(30)
.await
.unwrap();
assert_eq!(trend_7.daily_costs.len(), 7);
assert_eq!(trend_14.daily_costs.len(), 14);
assert_eq!(trend_30.daily_costs.len(), 30);
assert!(trend_7.projected_monthly_cost > 0.0);
assert!(trend_14.projected_monthly_cost > 0.0);
assert!(trend_30.projected_monthly_cost > 0.0);
}
#[tokio::test]
async fn test_trend_analysis_volatile_pattern() {
let mut fixture = TrendTestFixture::new().await;
let base_time = Utc::now() - Duration::days(10);
let volatile_costs = vec![0.5, 2.0, 0.3, 3.5, 0.1, 4.0, 0.2, 2.5, 0.8, 3.0];
for (day, &cost) in volatile_costs.iter().enumerate() {
let mut entry = CostEntry::new(
fixture.session_id,
format!("volatile_cmd_{}", day),
cost,
(cost * 100.0) as u32,
(cost * 200.0) as u32,
(cost * 1000.0) as u64,
"claude-3-opus".to_string(),
);
entry.timestamp = base_time + Duration::days(day as i64);
fixture.advanced_tracker.record_cost(entry).await.unwrap();
}
let trend = fixture
.advanced_tracker
.analyze_cost_trend(10)
.await
.unwrap();
assert_eq!(trend.period_days, 10);
assert_eq!(trend.daily_costs.len(), 10);
let total: f64 = trend.daily_costs.iter().map(|(_, cost)| cost).sum();
let mean = total / trend.daily_costs.len() as f64;
let variance: f64 = trend
.daily_costs
.iter()
.map(|(_, cost)| (cost - mean).powi(2))
.sum::<f64>()
/ trend.daily_costs.len() as f64;
assert!(variance > 1.0, "Variance should be high for volatile data");
}
#[tokio::test]
async fn test_trend_analysis_seasonal_pattern() {
let mut fixture = TrendTestFixture::new().await;
let base_time = Utc::now() - Duration::days(21);
for week in 0..3 {
for day in 0..7 {
let cost = if day < 5 {
2.0 + (day as f64 * 0.1)
} else {
0.5
};
let mut entry = CostEntry::new(
fixture.session_id,
format!("seasonal_cmd_w{}_d{}", week, day),
cost,
100,
200,
1000,
"claude-3-opus".to_string(),
);
entry.timestamp = base_time + Duration::days((week * 7 + day) as i64);
fixture.advanced_tracker.record_cost(entry).await.unwrap();
}
}
let trend = fixture
.advanced_tracker
.analyze_cost_trend(21)
.await
.unwrap();
assert_eq!(trend.period_days, 21);
assert_eq!(trend.daily_costs.len(), 21);
let mut weekday_total = 0.0;
let mut weekend_total = 0.0;
for (i, (_, cost)) in trend.daily_costs.iter().enumerate() {
if i % 7 < 5 {
weekday_total += cost;
} else {
weekend_total += cost;
}
}
assert!(
weekday_total > weekend_total * 2.0,
"Weekday costs should be significantly higher"
);
}
#[tokio::test]
async fn test_trend_prediction_linear_extrapolation() {
let mut fixture = TrendTestFixture::new().await;
let base_time = Utc::now() - Duration::days(10);
for day in 0..10 {
let cost = 1.0 + (day as f64 * 0.2); let mut entry = CostEntry::new(
fixture.session_id,
format!("linear_cmd_{}", day),
cost,
100,
200,
1000,
"claude-3-opus".to_string(),
);
entry.timestamp = base_time + Duration::days(day as i64);
fixture.advanced_tracker.record_cost(entry).await.unwrap();
}
let trend = fixture
.advanced_tracker
.analyze_cost_trend(10)
.await
.unwrap();
assert!(
trend.growth_rate > 0.0,
"Should have positive growth rate, got {}",
trend.growth_rate
);
assert!(
matches!(
trend.trend_direction,
TrendDirection::Increasing | TrendDirection::Stable
),
"Expected increasing or stable trend but got {:?}",
trend.trend_direction
);
let avg_cost = (1.0 + 2.8) / 2.0 * 10.0 / 10.0;
assert!(
trend.projected_monthly_cost > avg_cost * 30.0,
"Projection should account for growth"
);
assert!(
trend.projected_monthly_cost < 200.0,
"Projection should be reasonable"
);
}
#[tokio::test]
async fn test_trend_prediction_with_outliers() {
let mut fixture = TrendTestFixture::new().await;
let base_time = Utc::now() - Duration::days(15);
let costs_with_outliers = vec![
1.0, 1.1, 1.2, 10.0, 1.3, 1.4, 1.5, 1.6, 1.7, 15.0, 1.8, 1.9, 2.0, 2.1, 2.2, ];
for (day, &cost) in costs_with_outliers.iter().enumerate() {
let mut entry = CostEntry::new(
fixture.session_id,
format!("outlier_cmd_{}", day),
cost,
100,
200,
1000,
"claude-3-opus".to_string(),
);
entry.timestamp = base_time + Duration::days(day as i64);
fixture.advanced_tracker.record_cost(entry).await.unwrap();
}
let trend = fixture
.advanced_tracker
.analyze_cost_trend(15)
.await
.unwrap();
assert!(
trend.projected_monthly_cost > 0.0,
"Projection should be positive"
);
assert!(
trend.growth_rate.is_finite(),
"Growth rate should be finite"
);
assert_eq!(trend.daily_costs.len(), 15);
}
#[tokio::test]
async fn test_export_json_format() {
let mut fixture = TrendTestFixture::new().await;
fixture.add_increasing_trend_data(5, 1.0).await;
fixture.add_optimization_scenario_data().await;
let budget = Budget {
id: Uuid::new_v4().to_string(),
name: "Test Budget".to_string(),
limit_usd: 100.0,
period_days: 30,
scope: BudgetScope::Global,
alert_thresholds: vec![50.0, 80.0],
created_at: Utc::now(),
};
fixture.advanced_tracker.add_budget(budget).unwrap();
let json_path = fixture.temp_dir.path().join("export.json");
let filter = CostFilter::default();
let entries = fixture.advanced_tracker.get_entries(&filter).await.unwrap();
let json_content = serde_json::to_string_pretty(&entries).unwrap();
tokio::fs::write(&json_path, json_content).await.unwrap();
assert!(json_path.exists());
let content = tokio::fs::read_to_string(&json_path).await.unwrap();
let parsed: Vec<CostEntry> = serde_json::from_str(&content).unwrap();
assert!(!parsed.is_empty());
assert!(parsed.iter().all(|e| !e.id.is_empty()));
assert!(parsed.iter().all(|e| e.cost_usd >= 0.0));
assert!(parsed.iter().all(|e| !e.command_name.is_empty()));
}
#[tokio::test]
async fn test_export_csv_format() {
let mut fixture = TrendTestFixture::new().await;
let entries = vec![
("normal_command", 1.5, "claude-3-opus"),
("command,with,commas", 2.0, "claude-3-sonnet"),
("command\"with\"quotes", 1.0, "claude-3-haiku"),
("unicode_命令", 3.0, "claude-3-opus"),
];
for (cmd, cost, model) in entries {
let entry = CostEntry::new(
fixture.session_id,
cmd.to_string(),
cost,
100,
200,
1000,
model.to_string(),
);
fixture.advanced_tracker.record_cost(entry).await.unwrap();
}
let csv_path = fixture.temp_dir.path().join("export.csv");
fixture
.advanced_tracker
.export_csv(&csv_path)
.await
.unwrap();
assert!(csv_path.exists());
let content = tokio::fs::read_to_string(&csv_path).await.unwrap();
let lines: Vec<&str> = content.lines().collect();
assert!(lines.len() >= 5);
assert_eq!(lines[0], "id,session_id,command_name,cost_usd,input_tokens,output_tokens,timestamp,duration_ms,model");
assert!(content.contains("normal_command"));
assert!(content.contains("command,with,commas") || content.contains("\"command,with,commas\""));
assert!(content.contains("unicode_命令"));
}
#[tokio::test]
async fn test_export_summary_format() {
let mut fixture = TrendTestFixture::new().await;
fixture.add_increasing_trend_data(7, 0.5).await;
fixture.add_stable_trend_data(7, 1.0).await;
fixture.add_decreasing_trend_data(7, 2.0).await;
let summary_path = fixture.temp_dir.path().join("summary.json");
let global_summary = fixture.advanced_tracker.get_global_summary().await.unwrap();
let summary_json = serde_json::to_string_pretty(&global_summary).unwrap();
tokio::fs::write(&summary_path, summary_json).await.unwrap();
assert!(summary_path.exists());
let content = tokio::fs::read_to_string(&summary_path).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(parsed["total_cost"].as_f64().unwrap() > 0.0);
assert!(parsed["command_count"].as_u64().unwrap() > 0);
assert!(parsed["by_command"].is_object());
assert!(parsed["by_model"].is_object());
}
#[tokio::test]
async fn test_export_data_integrity() {
let mut fixture = TrendTestFixture::new().await;
let test_entries = vec![
("cmd1", 1.234567, 123, 456, 7890, "model1"),
("cmd2", 0.000001, 1, 2, 3, "model2"),
("cmd3", 999.999999, 999999, 888888, 777777, "model3"),
];
let mut original_entries = Vec::new();
for (cmd, cost, input, output, duration, model) in test_entries {
let entry = CostEntry::new(
fixture.session_id,
cmd.to_string(),
cost,
input,
output,
duration,
model.to_string(),
);
original_entries.push(entry.clone());
fixture.advanced_tracker.record_cost(entry).await.unwrap();
}
let json_path = fixture.temp_dir.path().join("integrity_test.json");
let entries = fixture
.advanced_tracker
.get_entries(&CostFilter::default())
.await
.unwrap();
let json_content = serde_json::to_string_pretty(&entries).unwrap();
tokio::fs::write(&json_path, json_content).await.unwrap();
let imported_content = tokio::fs::read_to_string(&json_path).await.unwrap();
let imported_entries: Vec<CostEntry> = serde_json::from_str(&imported_content).unwrap();
assert_eq!(imported_entries.len(), original_entries.len());
for (original, imported) in original_entries.iter().zip(imported_entries.iter()) {
assert_eq!(original.command_name, imported.command_name);
assert!((original.cost_usd - imported.cost_usd).abs() < 0.0000001);
assert_eq!(original.input_tokens, imported.input_tokens);
assert_eq!(original.output_tokens, imported.output_tokens);
assert_eq!(original.duration_ms, imported.duration_ms);
assert_eq!(original.model, imported.model);
}
}
#[tokio::test]
async fn test_export_large_dataset_performance() {
let mut fixture = TrendTestFixture::new().await;
let entry_count = 1000;
for i in 0..entry_count {
let entry = CostEntry::new(
fixture.session_id,
format!("perf_cmd_{}", i),
0.001 * (i + 1) as f64,
10 + i,
20 + i,
100 + i as u64,
format!("model_{}", i % 5),
);
fixture.advanced_tracker.record_cost(entry).await.unwrap();
}
let csv_path = fixture.temp_dir.path().join("large_export.csv");
let start = std::time::Instant::now();
fixture
.advanced_tracker
.export_csv(&csv_path)
.await
.unwrap();
let duration = start.elapsed();
assert!(
duration.as_millis() < 500,
"Export should complete within 500ms"
);
let content = tokio::fs::read_to_string(&csv_path).await.unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), entry_count as usize + 1); }
#[tokio::test]
async fn test_export_with_concurrent_modifications() {
let mut fixture = TrendTestFixture::new().await;
for i in 0..10 {
let entry = CostEntry::new(
fixture.session_id,
format!("concurrent_cmd_{}", i),
0.1 * (i + 1) as f64,
100,
200,
1000,
"claude-3-opus".to_string(),
);
fixture.advanced_tracker.record_cost(entry).await.unwrap();
}
let export_path = fixture.temp_dir.path().join("concurrent_export.csv");
fixture
.advanced_tracker
.export_csv(&export_path)
.await
.unwrap();
let entry = CostEntry::new(
fixture.session_id,
"concurrent_new_cmd".to_string(),
1.5,
150,
300,
1500,
"claude-3-opus".to_string(),
);
fixture.advanced_tracker.record_cost(entry).await.unwrap();
assert!(export_path.exists());
let content = tokio::fs::read_to_string(&export_path).await.unwrap();
let lines: Vec<&str> = content.lines().collect();
assert!(lines.len() >= 11); }
proptest! {
#[test]
fn prop_trend_analysis_consistency(
costs in prop::collection::vec(0.001f64..100.0f64, 5..50)
) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let mut fixture = TrendTestFixture::new().await;
let base_time = Utc::now() - Duration::days(costs.len() as i64);
for (i, &cost) in costs.iter().enumerate() {
let mut entry = CostEntry::new(
fixture.session_id,
format!("prop_cmd_{}", i),
cost,
100,
200,
1000,
"claude-3-opus".to_string(),
);
entry.timestamp = base_time + Duration::days(i as i64);
fixture.advanced_tracker.record_cost(entry).await.unwrap();
}
let trend = fixture.advanced_tracker.analyze_cost_trend(costs.len() as u32).await.unwrap();
prop_assert_eq!(trend.period_days, costs.len() as u32);
prop_assert_eq!(trend.daily_costs.len(), costs.len());
prop_assert!(trend.projected_monthly_cost >= 0.0);
prop_assert!(trend.growth_rate.is_finite());
prop_assert!(matches!(
trend.trend_direction,
TrendDirection::Increasing | TrendDirection::Decreasing | TrendDirection::Stable
));
Ok(())
})?;
}
#[test]
fn prop_export_data_preservation(
entries in prop::collection::vec(
(
"[a-zA-Z0-9_]{1,20}", // command name
0.001f64..100.0f64, // cost
1u32..10000u32, // input tokens
1u32..10000u32, // output tokens
100u64..100000u64, // duration
prop::sample::select(vec!["claude-3-opus", "claude-3-sonnet", "claude-3-haiku"]),
),
1..20
)
) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let mut fixture = TrendTestFixture::new().await;
for (cmd, cost, input, output, duration, model) in &entries {
let entry = CostEntry::new(
fixture.session_id,
cmd.clone(),
*cost,
*input,
*output,
*duration,
model.to_string(),
);
fixture.advanced_tracker.record_cost(entry).await.unwrap();
}
let json_path = fixture.temp_dir.path().join("prop_export.json");
let export_entries = fixture.advanced_tracker
.get_entries(&CostFilter::default()).await.unwrap();
let json = serde_json::to_string(&export_entries).unwrap();
tokio::fs::write(&json_path, &json).await.unwrap();
let imported_json = tokio::fs::read_to_string(&json_path).await.unwrap();
let imported: Vec<CostEntry> = serde_json::from_str(&imported_json).unwrap();
prop_assert_eq!(imported.len(), entries.len());
Ok(())
})?;
}
}
#[tokio::test]
async fn test_trend_analysis_single_data_point() {
let mut fixture = TrendTestFixture::new().await;
let entry = CostEntry::new(
fixture.session_id,
"single_cmd".to_string(),
5.0,
100,
200,
1000,
"claude-3-opus".to_string(),
);
fixture.advanced_tracker.record_cost(entry).await.unwrap();
let trend = fixture
.advanced_tracker
.analyze_cost_trend(7)
.await
.unwrap();
assert_eq!(trend.period_days, 7);
assert!(
trend.growth_rate.is_finite(),
"Growth rate should be finite but was {}",
trend.growth_rate
);
let non_zero_days = trend
.daily_costs
.iter()
.filter(|(_, cost)| *cost > 0.0)
.count();
assert_eq!(non_zero_days, 1);
}
#[tokio::test]
async fn test_export_with_special_characters() {
let mut fixture = TrendTestFixture::new().await;
let special_commands = vec![
"cmd<with>angles",
"cmd&with&ersand",
"cmd|with|pipe",
"cmd\twith\ttabs",
"cmd\nwith\nnewlines",
"cmd\\with\\backslashes",
];
for cmd in special_commands {
let entry = CostEntry::new(
fixture.session_id,
cmd.to_string(),
1.0,
100,
200,
1000,
"claude-3-opus".to_string(),
);
fixture.advanced_tracker.record_cost(entry).await.unwrap();
}
let csv_path = fixture.temp_dir.path().join("special_chars.csv");
fixture
.advanced_tracker
.export_csv(&csv_path)
.await
.unwrap();
let content = tokio::fs::read_to_string(&csv_path).await.unwrap();
assert!(csv_path.exists());
let lines: Vec<&str> = content.lines().collect();
assert!(lines.len() >= 7); }
#[tokio::test]
async fn test_trend_analysis_far_future_dates() {
let mut fixture = TrendTestFixture::new().await;
let mut future_entry = CostEntry::new(
fixture.session_id,
"future_cmd".to_string(),
10.0,
100,
200,
1000,
"claude-3-opus".to_string(),
);
future_entry.timestamp = Utc::now() + Duration::days(365);
fixture
.advanced_tracker
.record_cost(future_entry)
.await
.unwrap();
let normal_entry = CostEntry::new(
fixture.session_id,
"normal_cmd".to_string(),
5.0,
100,
200,
1000,
"claude-3-opus".to_string(),
);
fixture
.advanced_tracker
.record_cost(normal_entry)
.await
.unwrap();
let trend = fixture
.advanced_tracker
.analyze_cost_trend(30)
.await
.unwrap();
assert_eq!(trend.period_days, 30);
let total_cost: f64 = trend.daily_costs.iter().map(|(_, cost)| cost).sum();
assert!(
total_cost <= 5.0,
"Future entry should not be included in past trend"
);
}