sba-scouting/rust/tests/calc_integration.rs
Cal Corum ebe4196bfc Implement Phase 3: calc layer with matchup scoring, league stats, and score cache
Port the Python calc layer to Rust: league stat distributions (avg excludes zeros,
stdev N-1 includes zeros), weighted standardized matchup scoring with switch-hitter
resolution and pitcher inversion, and SHA-256-validated score cache with automatic
rebuild after card imports. 105 tests passing (76 unit + 5 integration + 24 DB).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:35:01 -06:00

278 lines
9.7 KiB
Rust

/// 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}"
);
}