Add test scaffold and mark Phase 1 tasks complete
Set up lib.rs for integration test access, add 50 tests covering calc engine (weights, standardization), model helpers (Player positions, Lineup JSON roundtrips), and full query layer (in-memory SQLite). Update PHASE1_PROJECT_PLAN.json to reflect all 12 tasks completed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2005307b7a
commit
3c0c206aba
@ -8,7 +8,7 @@
|
||||
"description": "Wire up the data pipeline foundation for the SBA Scout Rust TUI rewrite. Database schema creation, full query layer, config integration, and dependency additions.",
|
||||
"totalEstimatedHours": 18,
|
||||
"totalTasks": 12,
|
||||
"completedTasks": 0
|
||||
"completedTasks": 12
|
||||
},
|
||||
"categories": {
|
||||
"critical": "Must complete before any other phase can start",
|
||||
@ -23,8 +23,8 @@
|
||||
"description": "Create a sqlx migration (or embedded SQL) that defines CREATE TABLE statements for all 9 tables matching the Python SQLAlchemy models exactly. Must include all columns, types, defaults, foreign keys, and unique constraints. sqlx does not have ORM-style create_all — tables must be defined as raw SQL.",
|
||||
"category": "critical",
|
||||
"priority": 1,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
@ -48,8 +48,8 @@
|
||||
"description": "Port the Python get_session() context manager pattern to Rust. Need a way to acquire a connection from the pool, run queries, and handle commit/rollback. The Python version uses async context manager with auto-commit on success and auto-rollback on exception.",
|
||||
"category": "critical",
|
||||
"priority": 2,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["CRIT-001"],
|
||||
"files": [
|
||||
{
|
||||
@ -73,8 +73,8 @@
|
||||
"description": "Wire up the existing config.rs (figment-based Settings) into the application startup flow. Load settings in main(), pass to App, pass db_path to pool init. Currently main.rs ignores config entirely.",
|
||||
"category": "critical",
|
||||
"priority": 3,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["CRIT-001", "CRIT-002"],
|
||||
"files": [
|
||||
{
|
||||
@ -103,8 +103,8 @@
|
||||
"description": "Port all team queries from Python db/queries.py to Rust db/queries.rs: get_all_teams, get_team_by_abbrev, get_team_by_id.",
|
||||
"category": "high",
|
||||
"priority": 4,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["CRIT-002"],
|
||||
"files": [
|
||||
{
|
||||
@ -128,8 +128,8 @@
|
||||
"description": "Port all player queries: get_players_by_team, get_player_by_id, get_player_by_name, search_players, get_pitchers, get_batters, get_players_missing_cards.",
|
||||
"category": "high",
|
||||
"priority": 5,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["CRIT-002"],
|
||||
"files": [
|
||||
{
|
||||
@ -153,8 +153,8 @@
|
||||
"description": "Port card queries: get_batter_card, get_pitcher_card.",
|
||||
"category": "high",
|
||||
"priority": 6,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["CRIT-002"],
|
||||
"files": [
|
||||
{
|
||||
@ -178,8 +178,8 @@
|
||||
"description": "Port the composite roster query that fetches majors, minors, and IL players for the user's team. This is a high-level function that calls team + player queries internally.",
|
||||
"category": "high",
|
||||
"priority": 7,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["HIGH-001", "HIGH-002"],
|
||||
"files": [
|
||||
{
|
||||
@ -198,8 +198,8 @@
|
||||
"description": "Port sync status queries: get_sync_status and update_sync_status (upsert pattern).",
|
||||
"category": "high",
|
||||
"priority": 8,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["CRIT-002"],
|
||||
"files": [
|
||||
{
|
||||
@ -218,8 +218,8 @@
|
||||
"description": "Port matchup cache queries: get_cached_matchup, invalidate_matchup_cache. Note: MatchupCache table exists but is largely unused in practice — the StandardizedScoreCache is the primary cache. Still needed for completeness.",
|
||||
"category": "medium",
|
||||
"priority": 9,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["CRIT-002"],
|
||||
"files": [
|
||||
{
|
||||
@ -238,8 +238,8 @@
|
||||
"description": "Port lineup CRUD: get_lineups, get_lineup_by_name, save_lineup (upsert), delete_lineup.",
|
||||
"category": "medium",
|
||||
"priority": 10,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["CRIT-002"],
|
||||
"files": [
|
||||
{
|
||||
@ -258,8 +258,8 @@
|
||||
"description": "Add crates needed by Phase 2+ to Cargo.toml now so they're available: csv (CSV import), sha2 (cache hashing), regex (endurance parsing).",
|
||||
"category": "low",
|
||||
"priority": 11,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
@ -278,8 +278,8 @@
|
||||
"description": "Add deserialization helpers to the Lineup model so screens can easily work with batting_order and positions as typed Rust values instead of raw JSON strings.",
|
||||
"category": "low",
|
||||
"priority": 12,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["MED-002"],
|
||||
"files": [
|
||||
{
|
||||
|
||||
@ -41,3 +41,91 @@ pub fn calculate_weighted_score(
|
||||
let std_score = standardize_value(value, distribution, stat_weight.high_is_better);
|
||||
std_score as f64 * stat_weight.weight as f64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dist(avg: f64, stdev: f64) -> StatDistribution {
|
||||
StatDistribution { avg, stdev }
|
||||
}
|
||||
|
||||
// --- standardize_value tests ---
|
||||
|
||||
#[test]
|
||||
fn zero_value_always_returns_3() {
|
||||
let d = dist(5.0, 1.0);
|
||||
assert_eq!(standardize_value(0.0, &d, true), 3);
|
||||
assert_eq!(standardize_value(0.0, &d, false), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_far_above_mean_is_minus3_base() {
|
||||
// value > avg + 2*stdev => base_score = -3
|
||||
let d = dist(5.0, 1.0);
|
||||
// high_is_better=true: -(-3) = 3
|
||||
assert_eq!(standardize_value(7.5, &d, true), 3);
|
||||
// high_is_better=false: -3
|
||||
assert_eq!(standardize_value(7.5, &d, false), -3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_far_below_mean_is_plus3_base() {
|
||||
// value <= avg - 2*stdev => base_score = 3
|
||||
let d = dist(5.0, 1.0);
|
||||
// high_is_better=true: -(3) = -3
|
||||
assert_eq!(standardize_value(2.5, &d, true), -3);
|
||||
// high_is_better=false: 3
|
||||
assert_eq!(standardize_value(2.5, &d, false), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_at_mean_returns_zero_base() {
|
||||
// avg - 0.33*stdev < value < avg + 0.33*stdev => base_score = 0
|
||||
let d = dist(5.0, 1.0);
|
||||
assert_eq!(standardize_value(5.0, &d, true), 0);
|
||||
assert_eq!(standardize_value(5.0, &d, false), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_ranges_are_symmetric() {
|
||||
// Walk through all 7 buckets with a clean distribution
|
||||
let d = dist(10.0, 2.0);
|
||||
|
||||
// base_score mapping (raw, not flipped):
|
||||
// > 14.0 (avg+2*std) => -3
|
||||
// > 12.0 (avg+std) => -2
|
||||
// > 10.66 (avg+0.33*std) => -1
|
||||
// > 9.34 (avg-0.33*std) => 0
|
||||
// > 8.0 (avg-std) => 1
|
||||
// > 6.0 (avg-2*std) => 2
|
||||
// <= 6.0 => 3
|
||||
|
||||
// Test with high_is_better=false (no flip)
|
||||
assert_eq!(standardize_value(15.0, &d, false), -3);
|
||||
assert_eq!(standardize_value(13.0, &d, false), -2);
|
||||
assert_eq!(standardize_value(11.0, &d, false), -1);
|
||||
assert_eq!(standardize_value(10.0, &d, false), 0);
|
||||
assert_eq!(standardize_value(9.0, &d, false), 1);
|
||||
assert_eq!(standardize_value(7.0, &d, false), 2);
|
||||
assert_eq!(standardize_value(5.0, &d, false), 3);
|
||||
}
|
||||
|
||||
// --- calculate_weighted_score tests ---
|
||||
|
||||
#[test]
|
||||
fn weighted_score_multiplies_std_by_weight() {
|
||||
let d = dist(10.0, 2.0);
|
||||
let w = StatWeight { weight: 5, high_is_better: true };
|
||||
// value=15.0 => base=-3, flipped=3 => 3*5=15.0
|
||||
assert!((calculate_weighted_score(15.0, &d, &w) - 15.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighted_score_zero_value() {
|
||||
let d = dist(10.0, 2.0);
|
||||
let w = StatWeight { weight: 3, high_is_better: false };
|
||||
// value=0.0 => always 3 => 3*3=9.0
|
||||
assert!((calculate_weighted_score(0.0, &d, &w) - 9.0).abs() < f64::EPSILON);
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,3 +58,48 @@ pub const fn max_pitcher_score() -> i32 {
|
||||
pub const fn max_matchup_score() -> i32 {
|
||||
max_batter_score() + max_pitcher_score() // 135
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batter_weights_sum_to_22() {
|
||||
let sum: i32 = BATTER_WEIGHTS.iter().map(|(_, w)| w.weight).sum();
|
||||
assert_eq!(sum, 22, "batter weight sum changed — update max_batter_score");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pitcher_weights_sum_to_23() {
|
||||
let sum: i32 = PITCHER_WEIGHTS.iter().map(|(_, w)| w.weight).sum();
|
||||
assert_eq!(sum, 23, "pitcher weight sum changed — update max_pitcher_score");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_scores_match_weight_sums() {
|
||||
let batter_sum: i32 = BATTER_WEIGHTS.iter().map(|(_, w)| w.weight).sum();
|
||||
assert_eq!(max_batter_score(), 3 * batter_sum);
|
||||
|
||||
let pitcher_sum: i32 = PITCHER_WEIGHTS.iter().map(|(_, w)| w.weight).sum();
|
||||
assert_eq!(max_pitcher_score(), 3 * pitcher_sum);
|
||||
|
||||
assert_eq!(max_matchup_score(), max_batter_score() + max_pitcher_score());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batter_weights_have_9_stats() {
|
||||
assert_eq!(BATTER_WEIGHTS.len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pitcher_weights_have_9_stats() {
|
||||
assert_eq!(PITCHER_WEIGHTS.len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batter_and_pitcher_share_stat_names() {
|
||||
let batter_names: Vec<&str> = BATTER_WEIGHTS.iter().map(|(n, _)| *n).collect();
|
||||
let pitcher_names: Vec<&str> = PITCHER_WEIGHTS.iter().map(|(n, _)| *n).collect();
|
||||
assert_eq!(batter_names, pitcher_names, "stat names must match between batter and pitcher weights");
|
||||
}
|
||||
}
|
||||
|
||||
@ -286,3 +286,138 @@ pub struct SyncStatus {
|
||||
pub last_sync_count: Option<i64>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_player(pos_1: Option<&str>, pos_2: Option<&str>) -> Player {
|
||||
Player {
|
||||
id: 1,
|
||||
name: "Test Player".to_string(),
|
||||
season: 13,
|
||||
team_id: Some(1),
|
||||
swar: None,
|
||||
card_image: None,
|
||||
card_image_alt: None,
|
||||
headshot: None,
|
||||
vanity_card: None,
|
||||
pos_1: pos_1.map(|s| s.to_string()),
|
||||
pos_2: pos_2.map(|s| s.to_string()),
|
||||
pos_3: None,
|
||||
pos_4: None,
|
||||
pos_5: None,
|
||||
pos_6: None,
|
||||
pos_7: None,
|
||||
pos_8: None,
|
||||
hand: None,
|
||||
injury_rating: None,
|
||||
il_return: None,
|
||||
demotion_week: None,
|
||||
strat_code: None,
|
||||
bbref_id: None,
|
||||
sbaplayer_id: None,
|
||||
last_game: None,
|
||||
last_game2: None,
|
||||
synced_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_positions_collects_non_none() {
|
||||
let p = make_player(Some("SS"), Some("2B"));
|
||||
assert_eq!(p.positions(), vec!["SS", "2B"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_positions_skips_none() {
|
||||
let p = make_player(Some("CF"), None);
|
||||
assert_eq!(p.positions(), vec!["CF"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_is_pitcher_true_for_sp() {
|
||||
assert!(make_player(Some("SP"), None).is_pitcher());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_is_pitcher_true_for_rp_in_pos2() {
|
||||
assert!(make_player(Some("DH"), Some("RP")).is_pitcher());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_is_pitcher_false_for_batter() {
|
||||
assert!(!make_player(Some("SS"), None).is_pitcher());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_is_batter_true_for_position_players() {
|
||||
for pos in &["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"] {
|
||||
assert!(make_player(Some(pos), None).is_batter(), "{} should be a batter", pos);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_is_batter_false_for_pitcher() {
|
||||
assert!(!make_player(Some("SP"), None).is_batter());
|
||||
}
|
||||
|
||||
fn make_lineup() -> Lineup {
|
||||
Lineup {
|
||||
id: 1,
|
||||
name: "Test Lineup".to_string(),
|
||||
description: None,
|
||||
lineup_type: "standard".to_string(),
|
||||
batting_order: None,
|
||||
positions: None,
|
||||
starting_pitcher_id: None,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineup_batting_order_empty_when_none() {
|
||||
let l = make_lineup();
|
||||
assert!(l.batting_order_vec().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineup_batting_order_roundtrip() {
|
||||
let mut l = make_lineup();
|
||||
let order = vec![101, 102, 103, 104, 105, 106, 107, 108, 109];
|
||||
l.set_batting_order(&order);
|
||||
assert_eq!(l.batting_order_vec(), order);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineup_positions_empty_when_none() {
|
||||
let l = make_lineup();
|
||||
assert!(l.positions_map().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineup_positions_roundtrip() {
|
||||
let mut l = make_lineup();
|
||||
let mut positions = HashMap::new();
|
||||
positions.insert("SS".to_string(), 101);
|
||||
positions.insert("CF".to_string(), 102);
|
||||
l.set_positions(&positions);
|
||||
assert_eq!(l.positions_map(), positions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineup_batting_order_handles_invalid_json() {
|
||||
let mut l = make_lineup();
|
||||
l.batting_order = Some("not valid json".to_string());
|
||||
assert!(l.batting_order_vec().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineup_positions_handles_invalid_json() {
|
||||
let mut l = make_lineup();
|
||||
l.positions = Some("{bad}".to_string());
|
||||
assert!(l.positions_map().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
6
rust/src/lib.rs
Normal file
6
rust/src/lib.rs
Normal file
@ -0,0 +1,6 @@
|
||||
pub mod api;
|
||||
pub mod app;
|
||||
pub mod calc;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod screens;
|
||||
@ -1,17 +1,11 @@
|
||||
mod api;
|
||||
mod app;
|
||||
mod calc;
|
||||
mod config;
|
||||
mod db;
|
||||
mod screens;
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{self, Event, KeyCode};
|
||||
use ratatui::DefaultTerminal;
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
|
||||
use app::App;
|
||||
use config::Settings;
|
||||
use sba_scout::app::App;
|
||||
use sba_scout::config::{self, Settings};
|
||||
use sba_scout::db;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
|
||||
401
rust/tests/db_queries.rs
Normal file
401
rust/tests/db_queries.rs
Normal file
@ -0,0 +1,401 @@
|
||||
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_players_missing_cards(&pool, 13, "batter")
|
||||
.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");
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user