- README.md: full project docs with features, API endpoints, Glicko-2 explanation
- main.rs: doc comments for all 18 HTTP handlers
- db/mod.rs: schema and migration documentation
- models/mod.rs: Player struct and Glicko-2 parameter docs
- Fixed route syntax (:id instead of {id}) for Axum 0.7 compatibility
251 lines
8.0 KiB
Rust
251 lines
8.0 KiB
Rust
//! Integration tests for Pickleball ELO Tracker
|
|
|
|
use pickleball_elo::glicko::{GlickoRating, calculate_new_ratings};
|
|
use pickleball_elo::db;
|
|
|
|
/// Test Glicko-2 rating calculations
|
|
#[test]
|
|
fn test_glicko_rating_creation() {
|
|
let rating = GlickoRating::new_player();
|
|
assert_eq!(rating.rating, 1500.0);
|
|
assert_eq!(rating.rd, 350.0);
|
|
assert!((rating.volatility - 0.06).abs() < 0.001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_glicko_winner_gains_rating() {
|
|
let winner = GlickoRating::new_player();
|
|
let loser = GlickoRating::new_player();
|
|
|
|
let (new_winner, new_loser) = calculate_new_ratings(&winner, &loser, 1.0, 1.0);
|
|
|
|
// Winner should gain rating
|
|
assert!(new_winner.rating > winner.rating,
|
|
"Winner rating {} should be greater than {}", new_winner.rating, winner.rating);
|
|
|
|
// Loser should lose rating
|
|
assert!(new_loser.rating < loser.rating,
|
|
"Loser rating {} should be less than {}", new_loser.rating, loser.rating);
|
|
}
|
|
|
|
#[test]
|
|
fn test_glicko_rating_changes_are_symmetric() {
|
|
let player1 = GlickoRating::new_player();
|
|
let player2 = GlickoRating::new_player();
|
|
|
|
let (new_p1, new_p2) = calculate_new_ratings(&player1, &player2, 1.0, 1.0);
|
|
|
|
let p1_change = new_p1.rating - player1.rating;
|
|
let p2_change = new_p2.rating - player2.rating;
|
|
|
|
// Changes should be roughly symmetric (opposite signs)
|
|
assert!((p1_change + p2_change).abs() < 1.0,
|
|
"Rating changes should be symmetric: {} + {} = {}", p1_change, p2_change, p1_change + p2_change);
|
|
}
|
|
|
|
#[test]
|
|
fn test_glicko_bigger_upset_bigger_change() {
|
|
let favorite = GlickoRating { rating: 1800.0, rd: 100.0, volatility: 0.06 };
|
|
let underdog = GlickoRating { rating: 1400.0, rd: 100.0, volatility: 0.06 };
|
|
|
|
// Underdog wins (upset)
|
|
let (new_underdog, new_favorite) = calculate_new_ratings(&underdog, &favorite, 1.0, 1.0);
|
|
|
|
// Underdog should gain a lot
|
|
let underdog_gain = new_underdog.rating - underdog.rating;
|
|
assert!(underdog_gain > 20.0,
|
|
"Underdog upset gain {} should be significant", underdog_gain);
|
|
}
|
|
|
|
#[test]
|
|
fn test_glicko_rd_decreases_after_match() {
|
|
let player1 = GlickoRating { rating: 1500.0, rd: 200.0, volatility: 0.06 };
|
|
let player2 = GlickoRating { rating: 1500.0, rd: 200.0, volatility: 0.06 };
|
|
|
|
let (new_p1, _) = calculate_new_ratings(&player1, &player2, 1.0, 1.0);
|
|
|
|
// RD should decrease after playing (more certainty)
|
|
assert!(new_p1.rd < player1.rd,
|
|
"RD {} should decrease from {}", new_p1.rd, player1.rd);
|
|
}
|
|
|
|
#[test]
|
|
fn test_score_weighting_blowout_vs_close() {
|
|
let player1 = GlickoRating::new_player();
|
|
let player2 = GlickoRating::new_player();
|
|
|
|
// Blowout win (11-0)
|
|
let (blowout_winner, _) = calculate_new_ratings(&player1, &player2, 1.0, 1.5);
|
|
|
|
// Close win (11-9)
|
|
let (close_winner, _) = calculate_new_ratings(&player1, &player2, 1.0, 1.05);
|
|
|
|
// Blowout should give more rating
|
|
assert!(blowout_winner.rating > close_winner.rating,
|
|
"Blowout {} should give more than close {}", blowout_winner.rating, close_winner.rating);
|
|
}
|
|
|
|
/// Test database operations
|
|
#[tokio::test]
|
|
async fn test_database_creation() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let db_path = temp_dir.join("test_pickleball.db");
|
|
let db_str = db_path.to_str().unwrap();
|
|
|
|
// Clean up from previous runs
|
|
let _ = std::fs::remove_file(&db_path);
|
|
|
|
let pool = db::create_pool(db_str).await.expect("Failed to create pool");
|
|
|
|
// Verify tables exist
|
|
let result: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM players")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Players table should exist");
|
|
|
|
assert_eq!(result.0, 0, "Players table should be empty");
|
|
|
|
// Clean up
|
|
drop(pool);
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_player_crud() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let db_path = temp_dir.join("test_crud.db");
|
|
let db_str = db_path.to_str().unwrap();
|
|
let _ = std::fs::remove_file(&db_path);
|
|
|
|
let pool = db::create_pool(db_str).await.unwrap();
|
|
|
|
// Create player
|
|
sqlx::query("INSERT INTO players (name, email) VALUES ('Test Player', 'test@example.com')")
|
|
.execute(&pool)
|
|
.await
|
|
.expect("Should insert player");
|
|
|
|
// Read player
|
|
let player: (i64, String, Option<String>, f64) = sqlx::query_as(
|
|
"SELECT id, name, email, singles_rating FROM players WHERE name = 'Test Player'"
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Should find player");
|
|
|
|
assert_eq!(player.1, "Test Player");
|
|
assert_eq!(player.2, Some("test@example.com".to_string()));
|
|
assert_eq!(player.3, 1500.0); // Default rating
|
|
|
|
// Update player
|
|
sqlx::query("UPDATE players SET singles_rating = 1600.0 WHERE id = ?")
|
|
.bind(player.0)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("Should update player");
|
|
|
|
let updated: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
|
|
.bind(player.0)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(updated.0, 1600.0);
|
|
|
|
// Delete player
|
|
sqlx::query("DELETE FROM players WHERE id = ?")
|
|
.bind(player.0)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("Should delete player");
|
|
|
|
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM players")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(count.0, 0);
|
|
|
|
drop(pool);
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_match_recording() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let db_path = temp_dir.join("test_matches.db");
|
|
let db_str = db_path.to_str().unwrap();
|
|
let _ = std::fs::remove_file(&db_path);
|
|
|
|
let pool = db::create_pool(db_str).await.unwrap();
|
|
|
|
// Create two players
|
|
sqlx::query("INSERT INTO players (name) VALUES ('Player A')")
|
|
.execute(&pool).await.unwrap();
|
|
sqlx::query("INSERT INTO players (name) VALUES ('Player B')")
|
|
.execute(&pool).await.unwrap();
|
|
|
|
// Create a session
|
|
let session_id: i64 = sqlx::query_scalar(
|
|
"INSERT INTO sessions (notes) VALUES ('Test Session') RETURNING id"
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Create a match
|
|
let match_id: i64 = sqlx::query_scalar(
|
|
"INSERT INTO matches (session_id, match_type, team1_score, team2_score) VALUES (?, 'singles', 11, 5) RETURNING id"
|
|
)
|
|
.bind(session_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(match_id > 0, "Match should be created with valid ID");
|
|
|
|
// Verify match
|
|
let match_data: (String, i32, i32) = sqlx::query_as(
|
|
"SELECT match_type, team1_score, team2_score FROM matches WHERE id = ?"
|
|
)
|
|
.bind(match_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(match_data.0, "singles");
|
|
assert_eq!(match_data.1, 11);
|
|
assert_eq!(match_data.2, 5);
|
|
|
|
drop(pool);
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rating_bounds() {
|
|
// Test that ratings don't go below 0 or above unreasonable values
|
|
let very_low = GlickoRating { rating: 100.0, rd: 50.0, volatility: 0.06 };
|
|
let very_high = GlickoRating { rating: 2500.0, rd: 50.0, volatility: 0.06 };
|
|
|
|
let (new_low, _) = calculate_new_ratings(&very_low, &very_high, 0.0, 1.0);
|
|
|
|
assert!(new_low.rating > 0.0, "Rating should stay positive");
|
|
assert!(new_low.rd > 0.0, "RD should stay positive");
|
|
}
|
|
|
|
#[test]
|
|
fn test_draw_handling() {
|
|
let player1 = GlickoRating::new_player();
|
|
let player2 = GlickoRating::new_player();
|
|
|
|
// Score of 0.5 = draw
|
|
let (new_p1, new_p2) = calculate_new_ratings(&player1, &player2, 0.5, 1.0);
|
|
|
|
// In a draw between equal players, ratings shouldn't change much
|
|
let p1_change = (new_p1.rating - player1.rating).abs();
|
|
let p2_change = (new_p2.rating - player2.rating).abs();
|
|
|
|
assert!(p1_change < 1.0, "Draw should not change rating much: {}", p1_change);
|
|
assert!(p2_change < 1.0, "Draw should not change rating much: {}", p2_change);
|
|
}
|