sba-scouting/rust/tests/db_queries.rs
Cal Corum c5e1fb44a6 Simplify and deduplicate codebase (-261 lines)
Consolidate shared helpers (format_rating, format_swar, tier_style,
format_relative_time) into widgets/mod.rs and screens/mod.rs. Replace
heap allocations with stack arrays and HashSets, parallelize DB queries
with tokio::try_join, wrap schema init in transactions, use OnceLock for
invariant hashes, and fix clippy warnings. Auto-sync on dashboard mount
when last sync >24h ago. All 105 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:07:23 -06:00

400 lines
13 KiB
Rust

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_batters_missing_cards(&pool, 13).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");
}