/// Integration tests for the full calc pipeline: /// league stats → score cache → calculate_matchup (real-time vs cached). /// /// These tests verify that: /// - `rebuild_score_cache` populates the DB correctly /// - `calculate_matchup` (real-time) and `calculate_matchup_cached` agree /// - Switch-hitter resolution works end-to-end /// - Batter-only rating (no pitcher card) works end-to-end use sba_scout::{ calc::{ league_stats::BatterLeagueStats, matchup::{calculate_matchup, calculate_matchup_cached}, score_cache::rebuild_score_cache, }, db::{models::Player, queries, schema}, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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 } async fn insert_team(pool: &sqlx::SqlitePool, id: i64, abbrev: &str) { sqlx::query( "INSERT INTO teams (id, abbrev, short_name, long_name, season) VALUES (?, ?, ?, ?, 13)", ) .bind(id) .bind(abbrev) .bind(abbrev) .bind(format!("{abbrev} Long Name")) .execute(pool) .await .expect("failed to insert team"); } async fn insert_player( pool: &sqlx::SqlitePool, id: i64, name: &str, team_id: i64, pos_1: &str, hand: &str, ) { sqlx::query( "INSERT INTO players (id, name, season, team_id, pos_1, hand) VALUES (?, ?, 13, ?, ?, ?)", ) .bind(id) .bind(name) .bind(team_id) .bind(pos_1) .bind(hand) .execute(pool) .await .expect("failed to insert player"); } /// Fetch a Player struct from the DB by id. async fn fetch_player(pool: &sqlx::SqlitePool, id: i64) -> Player { sqlx::query_as("SELECT * FROM players WHERE id = ?") .bind(id) .fetch_one(pool) .await .expect("player not found") } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- /// Verify rebuild_score_cache runs without error and populates cache entries. /// /// With one batter card and one pitcher card, we expect 4 cache rows total: /// 2 splits for the batter (vlhp, vrhp) and 2 for the pitcher (vlhb, vrhb). #[tokio::test] async fn rebuild_score_cache_populates_entries() { let pool = test_pool().await; insert_team(&pool, 1, "TST").await; insert_player(&pool, 10, "Test Batter", 1, "CF", "R").await; insert_player(&pool, 11, "Test Pitcher", 1, "SP", "R").await; sqlx::query("INSERT INTO batter_cards (player_id) VALUES (?)") .bind(10) .execute(&pool) .await .unwrap(); sqlx::query("INSERT INTO pitcher_cards (player_id) VALUES (?)") .bind(11) .execute(&pool) .await .unwrap(); let result = rebuild_score_cache(&pool).await.expect("rebuild failed"); assert_eq!(result.batter_splits, 2, "expected 2 batter cache rows (vlhp, vrhp)"); assert_eq!(result.pitcher_splits, 2, "expected 2 pitcher cache rows (vlhb, vrhb)"); // Verify rows exist in DB let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM standardized_score_cache") .fetch_one(&pool) .await .unwrap(); assert_eq!(count, 4); } /// Verify that calculate_matchup (real-time) and calculate_matchup_cached agree /// on rating and tier for a standard R batter vs R pitcher matchup. /// /// All card stats default to 0.0 (zeroed). With zeroed cards and default league /// stats, both functions must return identical ratings. #[tokio::test] async fn realtime_and_cached_matchup_agree() { let pool = test_pool().await; insert_team(&pool, 1, "TST").await; insert_player(&pool, 10, "Test Batter", 1, "CF", "R").await; insert_player(&pool, 11, "Test Pitcher", 1, "SP", "R").await; sqlx::query("INSERT INTO batter_cards (player_id) VALUES (?)") .bind(10) .execute(&pool) .await .unwrap(); sqlx::query("INSERT INTO pitcher_cards (player_id) VALUES (?)") .bind(11) .execute(&pool) .await .unwrap(); rebuild_score_cache(&pool).await.expect("rebuild failed"); let batter = fetch_player(&pool, 10).await; let pitcher = fetch_player(&pool, 11).await; let batter_card = queries::get_batter_card(&pool, 10).await.unwrap().unwrap(); let pitcher_card = queries::get_pitcher_card(&pool, 11).await.unwrap().unwrap(); let b_stats = BatterLeagueStats::default(); let p_stats = sba_scout::calc::league_stats::PitcherLeagueStats::default(); let realtime = calculate_matchup( &batter, Some(&batter_card), &pitcher, Some(&pitcher_card), &b_stats, &p_stats, ); let cached = calculate_matchup_cached(&pool, &batter, Some(&batter_card), &pitcher, Some(&pitcher_card)) .await .expect("cached matchup failed"); let rt_rating = realtime.rating.expect("real-time should have rating"); let c_rating = cached.rating.expect("cached should have rating"); assert!( (rt_rating - c_rating).abs() < 0.01, "ratings differ: real-time={rt_rating}, cached={c_rating}" ); assert_eq!(realtime.tier, cached.tier, "tiers should match"); assert_eq!(realtime.batter_split, cached.batter_split); assert_eq!(realtime.pitcher_split, cached.pitcher_split); } /// Verify switch-hitter resolution: a player with hand="S" facing an R pitcher /// should bat left (effective_hand="L", batter_split="vRHP", pitcher_split="vLHB"). #[tokio::test] async fn switch_hitter_resolution_vs_right_pitcher() { let pool = test_pool().await; insert_team(&pool, 1, "TST").await; insert_player(&pool, 10, "Switch Batter", 1, "CF", "S").await; insert_player(&pool, 11, "Right Pitcher", 1, "SP", "R").await; sqlx::query("INSERT INTO batter_cards (player_id) VALUES (?)") .bind(10) .execute(&pool) .await .unwrap(); rebuild_score_cache(&pool).await.expect("rebuild failed"); let batter = fetch_player(&pool, 10).await; let pitcher = fetch_player(&pool, 11).await; let batter_card = queries::get_batter_card(&pool, 10).await.unwrap().unwrap(); let b_stats = BatterLeagueStats::default(); let p_stats = sba_scout::calc::league_stats::PitcherLeagueStats::default(); let result = calculate_matchup(&batter, Some(&batter_card), &pitcher, None, &b_stats, &p_stats); assert_eq!(result.batter_hand, "L", "switch hitter vs R pitcher should bat left"); assert_eq!(result.batter_split, "vRHP"); assert_eq!(result.pitcher_split, "vLHB"); assert!(result.rating.is_some()); } /// Verify switch-hitter resolution: hand="S" facing an L pitcher → bats right. #[tokio::test] async fn switch_hitter_resolution_vs_left_pitcher() { let pool = test_pool().await; insert_team(&pool, 1, "TST").await; insert_player(&pool, 10, "Switch Batter", 1, "CF", "S").await; insert_player(&pool, 11, "Left Pitcher", 1, "SP", "L").await; sqlx::query("INSERT INTO batter_cards (player_id) VALUES (?)") .bind(10) .execute(&pool) .await .unwrap(); rebuild_score_cache(&pool).await.expect("rebuild failed"); let batter = fetch_player(&pool, 10).await; let pitcher = fetch_player(&pool, 11).await; let batter_card = queries::get_batter_card(&pool, 10).await.unwrap().unwrap(); let b_stats = BatterLeagueStats::default(); let p_stats = sba_scout::calc::league_stats::PitcherLeagueStats::default(); let result = calculate_matchup(&batter, Some(&batter_card), &pitcher, None, &b_stats, &p_stats); assert_eq!(result.batter_hand, "R", "switch hitter vs L pitcher should bat right"); assert_eq!(result.batter_split, "vLHP"); assert_eq!(result.pitcher_split, "vRHB"); } /// Verify batter-only rating: when pitcher card is None, total equals batter component. /// /// With a zeroed batter card and default league stats, all stats standardize to 3 /// and the batter component = 3 * 22 = 66. #[tokio::test] async fn batter_only_rating_when_no_pitcher_card() { let pool = test_pool().await; insert_team(&pool, 1, "TST").await; insert_player(&pool, 10, "Test Batter", 1, "CF", "R").await; insert_player(&pool, 11, "Test Pitcher", 1, "SP", "R").await; sqlx::query("INSERT INTO batter_cards (player_id) VALUES (?)") .bind(10) .execute(&pool) .await .unwrap(); rebuild_score_cache(&pool).await.expect("rebuild failed"); let batter = fetch_player(&pool, 10).await; let pitcher = fetch_player(&pool, 11).await; let batter_card = queries::get_batter_card(&pool, 10).await.unwrap().unwrap(); let b_stats = BatterLeagueStats::default(); let p_stats = sba_scout::calc::league_stats::PitcherLeagueStats::default(); // Real-time: no pitcher card passed let rt = calculate_matchup(&batter, Some(&batter_card), &pitcher, None, &b_stats, &p_stats); let rating = rt.rating.expect("should have rating"); assert!( (rating - 66.0).abs() < f64::EPSILON, "batter-only zeroed rating should be 66.0, got {rating}" ); assert!(rt.pitcher_component.is_none()); // Cached: no pitcher card passed (pitcher has no card in DB) let cached = calculate_matchup_cached(&pool, &batter, Some(&batter_card), &pitcher, None) .await .expect("cached matchup failed"); let c_rating = cached.rating.expect("cached should have rating"); assert!( (c_rating - 66.0).abs() < f64::EPSILON, "cached batter-only zeroed rating should be 66.0, got {c_rating}" ); }