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:
Cal Corum 2026-02-26 20:02:01 -06:00
parent 2005307b7a
commit 3c0c206aba
7 changed files with 703 additions and 34 deletions

View File

@ -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": [
{

View File

@ -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);
}
}

View File

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

View File

@ -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
View File

@ -0,0 +1,6 @@
pub mod api;
pub mod app;
pub mod calc;
pub mod config;
pub mod db;
pub mod screens;

View File

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