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>
400 lines
13 KiB
Rust
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");
|
|
}
|