diff --git a/rust/PHASE1_PROJECT_PLAN.json b/rust/PHASE1_PROJECT_PLAN.json index 33aa391..7b02815 100644 --- a/rust/PHASE1_PROJECT_PLAN.json +++ b/rust/PHASE1_PROJECT_PLAN.json @@ -8,7 +8,7 @@ "description": "Wire up the data pipeline foundation for the SBA Scout Rust TUI rewrite. Database schema creation, full query layer, config integration, and dependency additions.", "totalEstimatedHours": 18, "totalTasks": 12, - "completedTasks": 0 + "completedTasks": 12 }, "categories": { "critical": "Must complete before any other phase can start", @@ -23,8 +23,8 @@ "description": "Create a sqlx migration (or embedded SQL) that defines CREATE TABLE statements for all 9 tables matching the Python SQLAlchemy models exactly. Must include all columns, types, defaults, foreign keys, and unique constraints. sqlx does not have ORM-style create_all — tables must be defined as raw SQL.", "category": "critical", "priority": 1, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": [], "files": [ { @@ -48,8 +48,8 @@ "description": "Port the Python get_session() context manager pattern to Rust. Need a way to acquire a connection from the pool, run queries, and handle commit/rollback. The Python version uses async context manager with auto-commit on success and auto-rollback on exception.", "category": "critical", "priority": 2, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["CRIT-001"], "files": [ { @@ -73,8 +73,8 @@ "description": "Wire up the existing config.rs (figment-based Settings) into the application startup flow. Load settings in main(), pass to App, pass db_path to pool init. Currently main.rs ignores config entirely.", "category": "critical", "priority": 3, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["CRIT-001", "CRIT-002"], "files": [ { @@ -103,8 +103,8 @@ "description": "Port all team queries from Python db/queries.py to Rust db/queries.rs: get_all_teams, get_team_by_abbrev, get_team_by_id.", "category": "high", "priority": 4, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["CRIT-002"], "files": [ { @@ -128,8 +128,8 @@ "description": "Port all player queries: get_players_by_team, get_player_by_id, get_player_by_name, search_players, get_pitchers, get_batters, get_players_missing_cards.", "category": "high", "priority": 5, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["CRIT-002"], "files": [ { @@ -153,8 +153,8 @@ "description": "Port card queries: get_batter_card, get_pitcher_card.", "category": "high", "priority": 6, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["CRIT-002"], "files": [ { @@ -178,8 +178,8 @@ "description": "Port the composite roster query that fetches majors, minors, and IL players for the user's team. This is a high-level function that calls team + player queries internally.", "category": "high", "priority": 7, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["HIGH-001", "HIGH-002"], "files": [ { @@ -198,8 +198,8 @@ "description": "Port sync status queries: get_sync_status and update_sync_status (upsert pattern).", "category": "high", "priority": 8, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["CRIT-002"], "files": [ { @@ -218,8 +218,8 @@ "description": "Port matchup cache queries: get_cached_matchup, invalidate_matchup_cache. Note: MatchupCache table exists but is largely unused in practice — the StandardizedScoreCache is the primary cache. Still needed for completeness.", "category": "medium", "priority": 9, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["CRIT-002"], "files": [ { @@ -238,8 +238,8 @@ "description": "Port lineup CRUD: get_lineups, get_lineup_by_name, save_lineup (upsert), delete_lineup.", "category": "medium", "priority": 10, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["CRIT-002"], "files": [ { @@ -258,8 +258,8 @@ "description": "Add crates needed by Phase 2+ to Cargo.toml now so they're available: csv (CSV import), sha2 (cache hashing), regex (endurance parsing).", "category": "low", "priority": 11, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": [], "files": [ { @@ -278,8 +278,8 @@ "description": "Add deserialization helpers to the Lineup model so screens can easily work with batting_order and positions as typed Rust values instead of raw JSON strings.", "category": "low", "priority": 12, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["MED-002"], "files": [ { diff --git a/rust/src/calc/matchup.rs b/rust/src/calc/matchup.rs index 7941123..b00d9e2 100644 --- a/rust/src/calc/matchup.rs +++ b/rust/src/calc/matchup.rs @@ -41,3 +41,91 @@ pub fn calculate_weighted_score( let std_score = standardize_value(value, distribution, stat_weight.high_is_better); std_score as f64 * stat_weight.weight as f64 } + +#[cfg(test)] +mod tests { + use super::*; + + fn dist(avg: f64, stdev: f64) -> StatDistribution { + StatDistribution { avg, stdev } + } + + // --- standardize_value tests --- + + #[test] + fn zero_value_always_returns_3() { + let d = dist(5.0, 1.0); + assert_eq!(standardize_value(0.0, &d, true), 3); + assert_eq!(standardize_value(0.0, &d, false), 3); + } + + #[test] + fn value_far_above_mean_is_minus3_base() { + // value > avg + 2*stdev => base_score = -3 + let d = dist(5.0, 1.0); + // high_is_better=true: -(-3) = 3 + assert_eq!(standardize_value(7.5, &d, true), 3); + // high_is_better=false: -3 + assert_eq!(standardize_value(7.5, &d, false), -3); + } + + #[test] + fn value_far_below_mean_is_plus3_base() { + // value <= avg - 2*stdev => base_score = 3 + let d = dist(5.0, 1.0); + // high_is_better=true: -(3) = -3 + assert_eq!(standardize_value(2.5, &d, true), -3); + // high_is_better=false: 3 + assert_eq!(standardize_value(2.5, &d, false), 3); + } + + #[test] + fn value_at_mean_returns_zero_base() { + // avg - 0.33*stdev < value < avg + 0.33*stdev => base_score = 0 + let d = dist(5.0, 1.0); + assert_eq!(standardize_value(5.0, &d, true), 0); + assert_eq!(standardize_value(5.0, &d, false), 0); + } + + #[test] + fn score_ranges_are_symmetric() { + // Walk through all 7 buckets with a clean distribution + let d = dist(10.0, 2.0); + + // base_score mapping (raw, not flipped): + // > 14.0 (avg+2*std) => -3 + // > 12.0 (avg+std) => -2 + // > 10.66 (avg+0.33*std) => -1 + // > 9.34 (avg-0.33*std) => 0 + // > 8.0 (avg-std) => 1 + // > 6.0 (avg-2*std) => 2 + // <= 6.0 => 3 + + // Test with high_is_better=false (no flip) + assert_eq!(standardize_value(15.0, &d, false), -3); + assert_eq!(standardize_value(13.0, &d, false), -2); + assert_eq!(standardize_value(11.0, &d, false), -1); + assert_eq!(standardize_value(10.0, &d, false), 0); + assert_eq!(standardize_value(9.0, &d, false), 1); + assert_eq!(standardize_value(7.0, &d, false), 2); + assert_eq!(standardize_value(5.0, &d, false), 3); + } + + // --- calculate_weighted_score tests --- + + #[test] + fn weighted_score_multiplies_std_by_weight() { + let d = dist(10.0, 2.0); + let w = StatWeight { weight: 5, high_is_better: true }; + // value=15.0 => base=-3, flipped=3 => 3*5=15.0 + assert!((calculate_weighted_score(15.0, &d, &w) - 15.0).abs() < f64::EPSILON); + } + + #[test] + fn weighted_score_zero_value() { + let d = dist(10.0, 2.0); + let w = StatWeight { weight: 3, high_is_better: false }; + // value=0.0 => always 3 => 3*3=9.0 + assert!((calculate_weighted_score(0.0, &d, &w) - 9.0).abs() < f64::EPSILON); + } +} diff --git a/rust/src/calc/weights.rs b/rust/src/calc/weights.rs index 70beddf..732daa8 100644 --- a/rust/src/calc/weights.rs +++ b/rust/src/calc/weights.rs @@ -58,3 +58,48 @@ pub const fn max_pitcher_score() -> i32 { pub const fn max_matchup_score() -> i32 { max_batter_score() + max_pitcher_score() // 135 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn batter_weights_sum_to_22() { + let sum: i32 = BATTER_WEIGHTS.iter().map(|(_, w)| w.weight).sum(); + assert_eq!(sum, 22, "batter weight sum changed — update max_batter_score"); + } + + #[test] + fn pitcher_weights_sum_to_23() { + let sum: i32 = PITCHER_WEIGHTS.iter().map(|(_, w)| w.weight).sum(); + assert_eq!(sum, 23, "pitcher weight sum changed — update max_pitcher_score"); + } + + #[test] + fn max_scores_match_weight_sums() { + let batter_sum: i32 = BATTER_WEIGHTS.iter().map(|(_, w)| w.weight).sum(); + assert_eq!(max_batter_score(), 3 * batter_sum); + + let pitcher_sum: i32 = PITCHER_WEIGHTS.iter().map(|(_, w)| w.weight).sum(); + assert_eq!(max_pitcher_score(), 3 * pitcher_sum); + + assert_eq!(max_matchup_score(), max_batter_score() + max_pitcher_score()); + } + + #[test] + fn batter_weights_have_9_stats() { + assert_eq!(BATTER_WEIGHTS.len(), 9); + } + + #[test] + fn pitcher_weights_have_9_stats() { + assert_eq!(PITCHER_WEIGHTS.len(), 9); + } + + #[test] + fn batter_and_pitcher_share_stat_names() { + let batter_names: Vec<&str> = BATTER_WEIGHTS.iter().map(|(n, _)| *n).collect(); + let pitcher_names: Vec<&str> = PITCHER_WEIGHTS.iter().map(|(n, _)| *n).collect(); + assert_eq!(batter_names, pitcher_names, "stat names must match between batter and pitcher weights"); + } +} diff --git a/rust/src/db/models.rs b/rust/src/db/models.rs index 6bd7782..57fac68 100644 --- a/rust/src/db/models.rs +++ b/rust/src/db/models.rs @@ -286,3 +286,138 @@ pub struct SyncStatus { pub last_sync_count: Option, pub last_error: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn make_player(pos_1: Option<&str>, pos_2: Option<&str>) -> Player { + Player { + id: 1, + name: "Test Player".to_string(), + season: 13, + team_id: Some(1), + swar: None, + card_image: None, + card_image_alt: None, + headshot: None, + vanity_card: None, + pos_1: pos_1.map(|s| s.to_string()), + pos_2: pos_2.map(|s| s.to_string()), + pos_3: None, + pos_4: None, + pos_5: None, + pos_6: None, + pos_7: None, + pos_8: None, + hand: None, + injury_rating: None, + il_return: None, + demotion_week: None, + strat_code: None, + bbref_id: None, + sbaplayer_id: None, + last_game: None, + last_game2: None, + synced_at: None, + } + } + + #[test] + fn player_positions_collects_non_none() { + let p = make_player(Some("SS"), Some("2B")); + assert_eq!(p.positions(), vec!["SS", "2B"]); + } + + #[test] + fn player_positions_skips_none() { + let p = make_player(Some("CF"), None); + assert_eq!(p.positions(), vec!["CF"]); + } + + #[test] + fn player_is_pitcher_true_for_sp() { + assert!(make_player(Some("SP"), None).is_pitcher()); + } + + #[test] + fn player_is_pitcher_true_for_rp_in_pos2() { + assert!(make_player(Some("DH"), Some("RP")).is_pitcher()); + } + + #[test] + fn player_is_pitcher_false_for_batter() { + assert!(!make_player(Some("SS"), None).is_pitcher()); + } + + #[test] + fn player_is_batter_true_for_position_players() { + for pos in &["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"] { + assert!(make_player(Some(pos), None).is_batter(), "{} should be a batter", pos); + } + } + + #[test] + fn player_is_batter_false_for_pitcher() { + assert!(!make_player(Some("SP"), None).is_batter()); + } + + fn make_lineup() -> Lineup { + Lineup { + id: 1, + name: "Test Lineup".to_string(), + description: None, + lineup_type: "standard".to_string(), + batting_order: None, + positions: None, + starting_pitcher_id: None, + created_at: None, + updated_at: None, + } + } + + #[test] + fn lineup_batting_order_empty_when_none() { + let l = make_lineup(); + assert!(l.batting_order_vec().is_empty()); + } + + #[test] + fn lineup_batting_order_roundtrip() { + let mut l = make_lineup(); + let order = vec![101, 102, 103, 104, 105, 106, 107, 108, 109]; + l.set_batting_order(&order); + assert_eq!(l.batting_order_vec(), order); + } + + #[test] + fn lineup_positions_empty_when_none() { + let l = make_lineup(); + assert!(l.positions_map().is_empty()); + } + + #[test] + fn lineup_positions_roundtrip() { + let mut l = make_lineup(); + let mut positions = HashMap::new(); + positions.insert("SS".to_string(), 101); + positions.insert("CF".to_string(), 102); + l.set_positions(&positions); + assert_eq!(l.positions_map(), positions); + } + + #[test] + fn lineup_batting_order_handles_invalid_json() { + let mut l = make_lineup(); + l.batting_order = Some("not valid json".to_string()); + assert!(l.batting_order_vec().is_empty()); + } + + #[test] + fn lineup_positions_handles_invalid_json() { + let mut l = make_lineup(); + l.positions = Some("{bad}".to_string()); + assert!(l.positions_map().is_empty()); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..d3bdefc --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,6 @@ +pub mod api; +pub mod app; +pub mod calc; +pub mod config; +pub mod db; +pub mod screens; diff --git a/rust/src/main.rs b/rust/src/main.rs index 9c96e37..147dea9 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,17 +1,11 @@ -mod api; -mod app; -mod calc; -mod config; -mod db; -mod screens; - use anyhow::Result; use crossterm::event::{self, Event, KeyCode}; use ratatui::DefaultTerminal; use sqlx::sqlite::SqlitePool; -use app::App; -use config::Settings; +use sba_scout::app::App; +use sba_scout::config::{self, Settings}; +use sba_scout::db; #[tokio::main] async fn main() -> Result<()> { diff --git a/rust/tests/db_queries.rs b/rust/tests/db_queries.rs new file mode 100644 index 0000000..d077707 --- /dev/null +++ b/rust/tests/db_queries.rs @@ -0,0 +1,401 @@ +use std::collections::HashMap; + +use sba_scout::db::{queries, schema}; + +/// Create an in-memory SQLite pool with all tables initialized. +async fn test_pool() -> sqlx::SqlitePool { + let pool = schema::init_pool(std::path::Path::new(":memory:")) + .await + .expect("failed to create in-memory pool"); + schema::create_tables(&pool) + .await + .expect("failed to create tables"); + pool +} + +/// Insert a test team and return its id. +async fn insert_team(pool: &sqlx::SqlitePool, id: i64, abbrev: &str, season: i64) { + sqlx::query( + "INSERT INTO teams (id, abbrev, short_name, long_name, season) VALUES (?, ?, ?, ?, ?)", + ) + .bind(id) + .bind(abbrev) + .bind(abbrev) + .bind(format!("{} Long Name", abbrev)) + .bind(season) + .execute(pool) + .await + .expect("failed to insert team"); +} + +/// Insert a test player and return its id. +async fn insert_player( + pool: &sqlx::SqlitePool, + id: i64, + name: &str, + team_id: i64, + season: i64, + pos_1: &str, +) { + sqlx::query( + "INSERT INTO players (id, name, season, team_id, pos_1) VALUES (?, ?, ?, ?, ?)", + ) + .bind(id) + .bind(name) + .bind(season) + .bind(team_id) + .bind(pos_1) + .execute(pool) + .await + .expect("failed to insert player"); +} + +// ============================================================================= +// Team Query Tests +// ============================================================================= + +#[tokio::test] +async fn get_all_teams_returns_teams_for_season() { + let pool = test_pool().await; + insert_team(&pool, 1, "WV", 13).await; + insert_team(&pool, 2, "NYM", 13).await; + insert_team(&pool, 3, "WV", 12).await; // different season + + let teams = queries::get_all_teams(&pool, 13, false).await.unwrap(); + assert_eq!(teams.len(), 2); +} + +#[tokio::test] +async fn get_all_teams_active_only_excludes_il_and_mil() { + let pool = test_pool().await; + insert_team(&pool, 1, "WV", 13).await; + insert_team(&pool, 2, "WVIL", 13).await; + insert_team(&pool, 3, "WVMiL", 13).await; + + let teams = queries::get_all_teams(&pool, 13, true).await.unwrap(); + assert_eq!(teams.len(), 1); + assert_eq!(teams[0].abbrev, "WV"); +} + +#[tokio::test] +async fn get_team_by_abbrev_finds_correct_team() { + let pool = test_pool().await; + insert_team(&pool, 1, "WV", 13).await; + insert_team(&pool, 2, "WV", 12).await; + + let team = queries::get_team_by_abbrev(&pool, "WV", 13).await.unwrap(); + assert!(team.is_some()); + assert_eq!(team.unwrap().id, 1); +} + +#[tokio::test] +async fn get_team_by_abbrev_returns_none_when_missing() { + let pool = test_pool().await; + let team = queries::get_team_by_abbrev(&pool, "NOPE", 13).await.unwrap(); + assert!(team.is_none()); +} + +#[tokio::test] +async fn get_team_by_id_works() { + let pool = test_pool().await; + insert_team(&pool, 42, "ATL", 13).await; + + let team = queries::get_team_by_id(&pool, 42).await.unwrap(); + assert!(team.is_some()); + assert_eq!(team.unwrap().abbrev, "ATL"); +} + +// ============================================================================= +// Player Query Tests +// ============================================================================= + +#[tokio::test] +async fn get_players_by_team_returns_sorted_by_name() { + let pool = test_pool().await; + insert_team(&pool, 1, "WV", 13).await; + insert_player(&pool, 10, "Zack Wheeler", 1, 13, "SP").await; + insert_player(&pool, 11, "Aaron Nola", 1, 13, "SP").await; + + let players = queries::get_players_by_team(&pool, 1).await.unwrap(); + assert_eq!(players.len(), 2); + assert_eq!(players[0].name, "Aaron Nola"); + assert_eq!(players[1].name, "Zack Wheeler"); +} + +#[tokio::test] +async fn get_player_by_name_is_case_insensitive() { + let pool = test_pool().await; + insert_team(&pool, 1, "WV", 13).await; + insert_player(&pool, 10, "Mike Trout", 1, 13, "CF").await; + + let player = queries::get_player_by_name(&pool, "mike trout", 13) + .await + .unwrap(); + assert!(player.is_some()); + assert_eq!(player.unwrap().id, 10); +} + +#[tokio::test] +async fn search_players_uses_like_pattern() { + let pool = test_pool().await; + insert_team(&pool, 1, "WV", 13).await; + insert_player(&pool, 10, "Mike Trout", 1, 13, "CF").await; + insert_player(&pool, 11, "Mike Piazza", 1, 13, "C").await; + insert_player(&pool, 12, "Derek Jeter", 1, 13, "SS").await; + + let results = queries::search_players(&pool, "Mike", 13, 10).await.unwrap(); + assert_eq!(results.len(), 2); +} + +#[tokio::test] +async fn search_players_respects_limit() { + let pool = test_pool().await; + insert_team(&pool, 1, "WV", 13).await; + insert_player(&pool, 10, "Mike Trout", 1, 13, "CF").await; + insert_player(&pool, 11, "Mike Piazza", 1, 13, "C").await; + + let results = queries::search_players(&pool, "Mike", 13, 1).await.unwrap(); + assert_eq!(results.len(), 1); +} + +#[tokio::test] +async fn get_pitchers_filters_by_position() { + let pool = test_pool().await; + insert_team(&pool, 1, "WV", 13).await; + insert_player(&pool, 10, "Zack Wheeler", 1, 13, "SP").await; + insert_player(&pool, 11, "Mike Trout", 1, 13, "CF").await; + + let pitchers = queries::get_pitchers(&pool, Some(1), Some(13)).await.unwrap(); + assert_eq!(pitchers.len(), 1); + assert_eq!(pitchers[0].name, "Zack Wheeler"); +} + +#[tokio::test] +async fn get_batters_filters_by_position() { + let pool = test_pool().await; + insert_team(&pool, 1, "WV", 13).await; + insert_player(&pool, 10, "Zack Wheeler", 1, 13, "SP").await; + insert_player(&pool, 11, "Mike Trout", 1, 13, "CF").await; + + let batters = queries::get_batters(&pool, Some(1), Some(13)).await.unwrap(); + assert_eq!(batters.len(), 1); + assert_eq!(batters[0].name, "Mike Trout"); +} + +#[tokio::test] +async fn get_players_missing_batter_cards() { + let pool = test_pool().await; + insert_team(&pool, 1, "WV", 13).await; + insert_player(&pool, 10, "Mike Trout", 1, 13, "CF").await; + insert_player(&pool, 11, "Mookie Betts", 1, 13, "RF").await; + + // Give Trout a batter card + sqlx::query("INSERT INTO batter_cards (player_id) VALUES (?)") + .bind(10) + .execute(&pool) + .await + .unwrap(); + + let missing = queries::get_players_missing_cards(&pool, 13, "batter") + .await + .unwrap(); + assert_eq!(missing.len(), 1); + assert_eq!(missing[0].name, "Mookie Betts"); +} + +// ============================================================================= +// Card Query Tests +// ============================================================================= + +#[tokio::test] +async fn get_batter_card_returns_none_when_missing() { + let pool = test_pool().await; + let card = queries::get_batter_card(&pool, 999).await.unwrap(); + assert!(card.is_none()); +} + +#[tokio::test] +async fn get_pitcher_card_returns_none_when_missing() { + let pool = test_pool().await; + let card = queries::get_pitcher_card(&pool, 999).await.unwrap(); + assert!(card.is_none()); +} + +// ============================================================================= +// Sync Status Tests +// ============================================================================= + +#[tokio::test] +async fn sync_status_upsert_creates_and_updates() { + let pool = test_pool().await; + + // First call creates + queries::update_sync_status(&pool, "teams", 25, None) + .await + .unwrap(); + let status = queries::get_sync_status(&pool, "teams").await.unwrap(); + assert!(status.is_some()); + assert_eq!(status.unwrap().last_sync_count, Some(25)); + + // Second call updates (upsert) + queries::update_sync_status(&pool, "teams", 30, Some("timeout")) + .await + .unwrap(); + let status = queries::get_sync_status(&pool, "teams").await.unwrap(); + let s = status.unwrap(); + assert_eq!(s.last_sync_count, Some(30)); + assert_eq!(s.last_error.as_deref(), Some("timeout")); +} + +// ============================================================================= +// Matchup Cache Tests +// ============================================================================= + +#[tokio::test] +async fn matchup_cache_returns_none_when_empty() { + let pool = test_pool().await; + let cache = queries::get_cached_matchup(&pool, 1, 2, "abc123") + .await + .unwrap(); + assert!(cache.is_none()); +} + +#[tokio::test] +async fn invalidate_matchup_cache_returns_count() { + let pool = test_pool().await; + insert_team(&pool, 1, "WV", 13).await; + insert_player(&pool, 10, "Batter", 1, 13, "CF").await; + insert_player(&pool, 11, "Pitcher", 1, 13, "SP").await; + + sqlx::query( + "INSERT INTO matchup_cache (batter_id, pitcher_id, rating, weights_hash) VALUES (?, ?, ?, ?)", + ) + .bind(10) + .bind(11) + .bind(75.5) + .bind("hash1") + .execute(&pool) + .await + .unwrap(); + + let deleted = queries::invalidate_matchup_cache(&pool).await.unwrap(); + assert_eq!(deleted, 1); +} + +// ============================================================================= +// Lineup CRUD Tests +// ============================================================================= + +#[tokio::test] +async fn save_and_get_lineup() { + let pool = test_pool().await; + let order = vec![1, 2, 3, 4, 5, 6, 7, 8, 9]; + let mut positions = HashMap::new(); + positions.insert("SS".to_string(), 1); + positions.insert("CF".to_string(), 2); + + queries::save_lineup(&pool, "vs LHP", &order, &positions, "standard", Some("Test lineup"), None) + .await + .unwrap(); + + let lineup = queries::get_lineup_by_name(&pool, "vs LHP").await.unwrap(); + assert!(lineup.is_some()); + let l = lineup.unwrap(); + assert_eq!(l.name, "vs LHP"); + assert_eq!(l.batting_order_vec(), order); + assert_eq!(l.positions_map(), positions); +} + +#[tokio::test] +async fn save_lineup_updates_existing() { + let pool = test_pool().await; + let positions = HashMap::new(); + + queries::save_lineup(&pool, "lineup1", &[1, 2, 3], &positions, "standard", None, None) + .await + .unwrap(); + queries::save_lineup(&pool, "lineup1", &[4, 5, 6], &positions, "standard", None, None) + .await + .unwrap(); + + let lineups = queries::get_lineups(&pool).await.unwrap(); + assert_eq!(lineups.len(), 1, "should update, not duplicate"); + assert_eq!(lineups[0].batting_order_vec(), vec![4, 5, 6]); +} + +#[tokio::test] +async fn delete_lineup_returns_true_when_found() { + let pool = test_pool().await; + let positions = HashMap::new(); + + queries::save_lineup(&pool, "doomed", &[1], &positions, "standard", None, None) + .await + .unwrap(); + assert!(queries::delete_lineup(&pool, "doomed").await.unwrap()); + assert!(!queries::delete_lineup(&pool, "doomed").await.unwrap()); +} + +#[tokio::test] +async fn get_lineups_returns_sorted_by_name() { + let pool = test_pool().await; + let positions = HashMap::new(); + + queries::save_lineup(&pool, "Zulu", &[1], &positions, "standard", None, None) + .await + .unwrap(); + queries::save_lineup(&pool, "Alpha", &[2], &positions, "standard", None, None) + .await + .unwrap(); + + let lineups = queries::get_lineups(&pool).await.unwrap(); + assert_eq!(lineups[0].name, "Alpha"); + assert_eq!(lineups[1].name, "Zulu"); +} + +// ============================================================================= +// Roster Tests +// ============================================================================= + +#[tokio::test] +async fn get_my_roster_assembles_majors_il_minors() { + let pool = test_pool().await; + insert_team(&pool, 1, "WV", 13).await; + insert_team(&pool, 2, "WVIL", 13).await; + insert_team(&pool, 3, "WVMiL", 13).await; + + insert_player(&pool, 10, "Major Player", 1, 13, "SS").await; + insert_player(&pool, 11, "IL Player", 2, 13, "SP").await; + insert_player(&pool, 12, "Minor Player", 3, 13, "CF").await; + + let roster = queries::get_my_roster(&pool, "WV", 13).await.unwrap(); + assert_eq!(roster.majors.len(), 1); + assert_eq!(roster.il.len(), 1); + assert_eq!(roster.minors.len(), 1); + assert_eq!(roster.majors[0].name, "Major Player"); + assert_eq!(roster.il[0].name, "IL Player"); + assert_eq!(roster.minors[0].name, "Minor Player"); +} + +#[tokio::test] +async fn get_my_roster_returns_empty_when_team_missing() { + let pool = test_pool().await; + let roster = queries::get_my_roster(&pool, "NOPE", 13).await.unwrap(); + assert!(roster.majors.is_empty()); + assert!(roster.il.is_empty()); + assert!(roster.minors.is_empty()); +} + +// ============================================================================= +// Schema Tests +// ============================================================================= + +#[tokio::test] +async fn reset_database_drops_and_recreates() { + let pool = test_pool().await; + insert_team(&pool, 1, "WV", 13).await; + + schema::reset_database(&pool).await.unwrap(); + + let teams = queries::get_all_teams(&pool, 13, false).await.unwrap(); + assert!(teams.is_empty(), "tables should be empty after reset"); +}