use crate::helpers::{
run_cs_with_env, run_cs_without_home,
stdout, stderr, assert_exit,
write_stats_cache, write_stats_cache_raw,
DayEntry,
};
use tempfile::TempDir;
#[ test ]
fn u01_usage_missing_stats_file_exits_2()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
std::fs::create_dir_all( dir.path().join( ".claude" ) ).unwrap();
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 2 );
let err = stderr( &out );
assert!( err.contains( "stats-cache.json" ), "error must mention stats-cache.json, got:\n{err}" );
}
#[ test ]
fn u02_usage_empty_stats_file_exits_2()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache_raw( dir.path(), "" );
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 2 );
let err = stderr( &out );
assert!( err.contains( "malformed" ), "error must mention malformed JSON, got:\n{err}" );
}
#[ test ]
fn u03_usage_no_daily_model_tokens_exits_2()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache_raw( dir.path(), r#"{"lastComputedDate":"2026-03-07"}"# );
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 2 );
let err = stderr( &out );
assert!( err.contains( "dailyModelTokens" ), "error must mention dailyModelTokens, got:\n{err}" );
}
#[ test ]
fn u04_usage_missing_last_computed_date_exits_2()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache_raw( dir.path(), r#"{"dailyModelTokens":[]}"# );
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 2 );
let err = stderr( &out );
assert!(
err.contains( "lastComputedDate" ),
"error must mention lastComputedDate, got:\n{err}",
);
}
#[ test ]
fn u05_usage_home_unset_exits_2()
{
let out = run_cs_without_home( &[ ".usage" ] );
assert_exit( &out, 2 );
}
#[ test ]
fn u06_usage_empty_daily_array_shows_zero()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[] );
let out = run_cs_with_env( &[ ".usage", "v::0" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
assert!( text.contains( "0 total" ), "empty array must show 0 total, got:\n{text}" );
}
#[ test ]
fn u07_usage_single_model_single_day()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 5000 ) ] },
] );
let out = run_cs_with_env( &[ ".usage", "v::0" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
assert!( text.contains( "5.0K total" ), "must show 5.0K total, got:\n{text}" );
assert!( text.contains( "sonnet-4-6" ), "must show shortened model name, got:\n{text}" );
}
#[ test ]
fn u08_usage_multiple_models_sorted_desc()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![
( "claude-haiku-4-5-20251001", 1000 ),
( "claude-sonnet-4-6", 5000 ),
( "claude-opus-4-6", 3000 ),
] },
] );
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
let pos_sonnet = text.find( "sonnet" ).expect( "must contain sonnet" );
let pos_opus = text.find( "opus" ).expect( "must contain opus" );
let pos_haiku = text.find( "haiku" ).expect( "must contain haiku" );
assert!(
pos_sonnet < pos_opus && pos_opus < pos_haiku,
"models must be sorted desc by tokens: sonnet > opus > haiku\ngot:\n{text}",
);
}
#[ test ]
fn u09_usage_model_name_shortening()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![
( "claude-haiku-4-5-20251001", 1000 ),
( "glm-4.5-air", 500 ),
] },
] );
let out = run_cs_with_env( &[ ".usage", "v::0" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
assert!( text.contains( "haiku-4-5" ), "must strip date suffix, got:\n{text}" );
assert!( !text.contains( "20251001" ), "date suffix must be removed, got:\n{text}" );
assert!( text.contains( "glm-4.5-air" ), "non-claude model must be unchanged, got:\n{text}" );
}
#[ test ]
fn u10_usage_v0_compact_single_line()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 2_000_000 ) ] },
] );
let out = run_cs_with_env( &[ ".usage", "v::0" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
let lines : Vec< &str > = text.trim().lines().collect();
assert_eq!( lines.len(), 1, "v::0 must be a single line, got:\n{text}" );
assert!( text.contains( "total" ), "v::0 must contain 'total', got:\n{text}" );
}
#[ test ]
fn u11_usage_v1_default_table()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 2_000_000 ) ] },
] );
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
assert!( text.contains( "Usage" ), "v::1 must have header, got:\n{text}" );
assert!( text.contains( "Total" ), "v::1 must have Total row, got:\n{text}" );
assert!( text.contains( '%' ), "v::1 must have percentage, got:\n{text}" );
assert!( !text.contains( "Daily" ), "v::1 must not have Daily section, got:\n{text}" );
}
#[ test ]
fn u12_usage_v2_daily_breakdown()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-06", models: vec![ ( "claude-sonnet-4-6", 1_000_000 ) ] },
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 2_000_000 ) ] },
] );
let out = run_cs_with_env( &[ ".usage", "v::2" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
assert!( text.contains( "Daily" ), "v::2 must have Daily section, got:\n{text}" );
assert!( text.contains( "2026-03-07" ), "v::2 must show individual dates, got:\n{text}" );
assert!( text.contains( "2026-03-06" ), "v::2 must show both dates, got:\n{text}" );
let pos_07 = text.find( "2026-03-07" ).unwrap();
let pos_06 = text.find( "2026-03-06" ).unwrap();
assert!( pos_07 < pos_06, "daily must be newest first, got:\n{text}" );
}
#[ test ]
fn u13_usage_json_valid()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![
( "claude-sonnet-4-6", 5000 ),
( "claude-opus-4-6", 3000 ),
] },
] );
let out = run_cs_with_env( &[ ".usage", "format::json" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
let parsed : serde_json::Value = serde_json::from_str( text.trim() )
.unwrap_or_else( |e| panic!( "JSON must be valid: {e}\ngot:\n{text}" ) );
assert_eq!( parsed[ "period_days" ], 7, "period_days must be 7" );
assert_eq!( parsed[ "total_tokens" ], 8000, "total must be 8000" );
assert!( parsed[ "by_model" ].is_array(), "by_model must be an array" );
let models = parsed[ "by_model" ].as_array().unwrap();
assert_eq!( models.len(), 2, "must have 2 models" );
assert_eq!( models[ 0 ][ "model" ], "sonnet-4-6", "first model (highest) must be sonnet" );
}
#[ test ]
fn u14_usage_filters_outside_7day_window()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-02-28", models: vec![ ( "claude-sonnet-4-6", 9_999_999 ) ] },
DayEntry { date: "2026-03-01", models: vec![ ( "claude-sonnet-4-6", 1000 ) ] },
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 2000 ) ] },
DayEntry { date: "2026-03-08", models: vec![ ( "claude-sonnet-4-6", 9_999_999 ) ] },
] );
let out = run_cs_with_env( &[ ".usage", "format::json" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
let parsed : serde_json::Value = serde_json::from_str( text.trim() ).unwrap();
assert_eq!( parsed[ "total_tokens" ], 3000, "must only sum in-window entries, got:\n{text}" );
}
#[ test ]
fn u15_usage_month_boundary()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-03" ), &[
DayEntry { date: "2026-02-24", models: vec![ ( "claude-sonnet-4-6", 999 ) ] },
DayEntry { date: "2026-02-25", models: vec![ ( "claude-sonnet-4-6", 100 ) ] },
DayEntry { date: "2026-03-03", models: vec![ ( "claude-sonnet-4-6", 200 ) ] },
] );
let out = run_cs_with_env( &[ ".usage", "format::json" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
let parsed : serde_json::Value = serde_json::from_str( text.trim() ).unwrap();
assert_eq!( parsed[ "period_start" ], "2026-02-25", "start must cross month boundary" );
assert_eq!( parsed[ "total_tokens" ], 300, "must include 02-25 and 03-03, got:\n{text}" );
}
#[ test ]
fn u16_usage_year_boundary()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-01-03" ), &[
DayEntry { date: "2025-12-27", models: vec![ ( "claude-sonnet-4-6", 999 ) ] },
DayEntry { date: "2025-12-28", models: vec![ ( "claude-sonnet-4-6", 100 ) ] },
DayEntry { date: "2026-01-03", models: vec![ ( "claude-sonnet-4-6", 200 ) ] },
] );
let out = run_cs_with_env( &[ ".usage", "format::json" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
let parsed : serde_json::Value = serde_json::from_str( text.trim() ).unwrap();
assert_eq!( parsed[ "period_start" ], "2025-12-28", "start must cross year boundary" );
assert_eq!( parsed[ "total_tokens" ], 300, "must include 12-28 and 01-03, got:\n{text}" );
}
#[ test ]
fn u17_usage_leap_year_boundary()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2024-03-02" ), &[
DayEntry { date: "2024-02-24", models: vec![ ( "claude-sonnet-4-6", 999 ) ] },
DayEntry { date: "2024-02-25", models: vec![ ( "claude-sonnet-4-6", 100 ) ] },
DayEntry { date: "2024-03-02", models: vec![ ( "claude-sonnet-4-6", 200 ) ] },
] );
let out = run_cs_with_env( &[ ".usage", "format::json" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
let parsed : serde_json::Value = serde_json::from_str( text.trim() ).unwrap();
assert_eq!( parsed[ "period_start" ], "2024-02-25", "leap year: Mar 2 - 6 = Feb 25" );
assert_eq!( parsed[ "total_tokens" ], 300 );
}
#[ test ]
fn u18_usage_token_format_boundaries()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 999 ) ] },
] );
let out = run_cs_with_env( &[ ".usage", "v::0" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
assert!( text.contains( "999 total" ), "999 must display as '999', got:\n{text}" );
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 1000 ) ] },
] );
let out = run_cs_with_env( &[ ".usage", "v::0" ], &[ ( "HOME", home ) ] );
let text = stdout( &out );
assert!( text.contains( "1.0K total" ), "1000 must display as '1.0K', got:\n{text}" );
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 999_949 ) ] },
] );
let out = run_cs_with_env( &[ ".usage", "v::0" ], &[ ( "HOME", home ) ] );
let text = stdout( &out );
assert!( text.contains( "999.9K total" ), "999_949 must display as '999.9K', got:\n{text}" );
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 999_950 ) ] },
] );
let out = run_cs_with_env( &[ ".usage", "v::0" ], &[ ( "HOME", home ) ] );
let text = stdout( &out );
assert!( text.contains( "1.0M total" ), "999_950 must display as '1.0M' (not '1000.0K'), got:\n{text}" );
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 1_000_000 ) ] },
] );
let out = run_cs_with_env( &[ ".usage", "v::0" ], &[ ( "HOME", home ) ] );
let text = stdout( &out );
assert!( text.contains( "1.0M total" ), "1_000_000 must display as '1.0M', got:\n{text}" );
}
#[ test ]
fn u19_usage_multi_day_aggregation()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-05", models: vec![ ( "claude-sonnet-4-6", 1000 ) ] },
DayEntry { date: "2026-03-06", models: vec![ ( "claude-sonnet-4-6", 2000 ) ] },
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 3000 ) ] },
] );
let out = run_cs_with_env( &[ ".usage", "format::json" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
let parsed : serde_json::Value = serde_json::from_str( text.trim() ).unwrap();
assert_eq!( parsed[ "total_tokens" ], 6000, "must aggregate 1000+2000+3000=6000" );
}
#[ test ]
fn u20_usage_malformed_entries_skipped()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache_raw( dir.path(), r#"{
"lastComputedDate": "2026-03-07",
"dailyModelTokens": [
{"tokensByModel": {"claude-sonnet-4-6": 100}},
{"date": "2026-03-07", "tokensByModel": {"claude-sonnet-4-6": 500}},
{"date": "2026-03-06"}
]
}"# );
let out = run_cs_with_env( &[ ".usage", "format::json" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
let parsed : serde_json::Value = serde_json::from_str( text.trim() ).unwrap();
assert_eq!( parsed[ "total_tokens" ], 500, "only valid entries must be counted, got:\n{text}" );
}
#[ test ]
fn u21_usage_json_empty_by_model()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[] );
let out = run_cs_with_env( &[ ".usage", "format::json" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
let parsed : serde_json::Value = serde_json::from_str( text.trim() )
.unwrap_or_else( |e| panic!( "empty by_model must produce valid JSON: {e}\ngot:\n{text}" ) );
assert_eq!( parsed[ "total_tokens" ], 0 );
assert!( parsed[ "by_model" ].as_array().unwrap().is_empty() );
}
#[ test ]
fn u22_usage_single_model_100_percent()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 5000 ) ] },
] );
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
assert!( text.contains( "100.0%" ), "single model must show 100.0%, got:\n{text}" );
}
#[ test ]
fn u23_usage_v1_comma_formatted_tokens()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 1_234_567 ) ] },
] );
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
assert!( text.contains( "1,234,567" ), "v::1 must show comma-formatted tokens, got:\n{text}" );
}
#[ test ]
fn u24_usage_v1_shows_period()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_stats_cache( dir.path(), Some( "2026-03-07" ), &[
DayEntry { date: "2026-03-07", models: vec![ ( "claude-sonnet-4-6", 100 ) ] },
] );
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
assert!( text.contains( "2026-03-01" ), "must show period start, got:\n{text}" );
assert!( text.contains( "2026-03-07" ), "must show period end, got:\n{text}" );
}