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>
278 lines
9.7 KiB
Rust
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}"
|
|
);
|
|
}
|