Comprehensive test suite: 99 tests

ELO System Tests (71):
- Calculator: expected scores, rating updates, K-factors
- Doubles: effective opponent calculations
- Score weights: per-point scoring
- Integration: convergence, conservation, symmetry
- Stress: extreme ratings, edge cases, floating point

Edge Case Tests (18):
- Special characters in names
- Rating extremes (1 to 3000)
- Score extremes (0-0, 11-0, overtime)
- Empty database queries
- SQL injection protection
- Concurrent access
- Session management

Database Tests (6):
- Player CRUD
- Match recording
- Leaderboard queries
- Daily summaries

Match Reversal Tests (4):
- Singles match reversal
- Doubles match reversal
- Multiple match reversal (LIFO)
- Partial reversal preserves other matches
This commit is contained in:
Split 2026-02-26 12:49:16 -05:00
parent b174da3dc5
commit 4fbb803a66

625
tests/match_reversal.rs Normal file
View File

@ -0,0 +1,625 @@
//! Match reversal/deletion tests
//!
//! Ensures that deleting matches correctly reverts ratings
use sqlx::SqlitePool;
use sqlx::sqlite::SqlitePoolOptions;
async fn setup_test_db() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("Failed to create test database");
sqlx::query(r#"
CREATE TABLE players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
email TEXT DEFAULT '',
singles_rating REAL DEFAULT 1500.0,
singles_rd REAL DEFAULT 350.0,
doubles_rating REAL DEFAULT 1500.0,
doubles_rd REAL DEFAULT 350.0,
created_at TEXT DEFAULT (datetime('now'))
)
"#).execute(&pool).await.unwrap();
sqlx::query(r#"
CREATE TABLE matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_type TEXT NOT NULL,
team1_score INTEGER NOT NULL,
team2_score INTEGER NOT NULL,
timestamp TEXT DEFAULT (datetime('now')),
session_id INTEGER
)
"#).execute(&pool).await.unwrap();
sqlx::query(r#"
CREATE TABLE match_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
team INTEGER NOT NULL,
rating_before REAL,
rating_after REAL,
rating_change REAL,
FOREIGN KEY (match_id) REFERENCES matches(id),
FOREIGN KEY (player_id) REFERENCES players(id)
)
"#).execute(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn test_singles_match_reversal() {
let pool = setup_test_db().await;
// Create two players
sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)")
.bind("Reversal A")
.bind(1500.0)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)")
.bind("Reversal B")
.bind(1500.0)
.execute(&pool)
.await
.unwrap();
let pa_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Reversal A")
.fetch_one(&pool)
.await
.unwrap();
let pb_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Reversal B")
.fetch_one(&pool)
.await
.unwrap();
// Record match with rating changes
let match_result = sqlx::query(
"INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)"
)
.bind("singles")
.bind(11)
.bind(5)
.execute(&pool)
.await
.unwrap();
let match_id = match_result.last_insert_rowid();
let rating_change_a = 10.0;
let rating_change_b = -10.0;
// Record participants with rating changes
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team, rating_before, rating_after, rating_change) VALUES (?, ?, ?, ?, ?, ?)"
)
.bind(match_id)
.bind(pa_id.0)
.bind(1)
.bind(1500.0)
.bind(1510.0)
.bind(rating_change_a)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team, rating_before, rating_after, rating_change) VALUES (?, ?, ?, ?, ?, ?)"
)
.bind(match_id)
.bind(pb_id.0)
.bind(2)
.bind(1500.0)
.bind(1490.0)
.bind(rating_change_b)
.execute(&pool)
.await
.unwrap();
// Apply rating changes
sqlx::query("UPDATE players SET singles_rating = singles_rating + ? WHERE id = ?")
.bind(rating_change_a)
.bind(pa_id.0)
.execute(&pool)
.await
.unwrap();
sqlx::query("UPDATE players SET singles_rating = singles_rating + ? WHERE id = ?")
.bind(rating_change_b)
.bind(pb_id.0)
.execute(&pool)
.await
.unwrap();
// Verify ratings changed
let rating_a: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
.bind(pa_id.0)
.fetch_one(&pool)
.await
.unwrap();
assert!((rating_a.0 - 1510.0).abs() < 0.01);
let rating_b: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
.bind(pb_id.0)
.fetch_one(&pool)
.await
.unwrap();
assert!((rating_b.0 - 1490.0).abs() < 0.01);
// NOW REVERSE THE MATCH
// Get rating changes to revert
let participants: Vec<(i64, f64)> = sqlx::query_as(
"SELECT player_id, rating_change FROM match_participants WHERE match_id = ?"
)
.bind(match_id)
.fetch_all(&pool)
.await
.unwrap();
// Revert each player's rating
for (player_id, change) in participants {
sqlx::query("UPDATE players SET singles_rating = singles_rating - ? WHERE id = ?")
.bind(change)
.bind(player_id)
.execute(&pool)
.await
.unwrap();
}
// Delete match participants and match
sqlx::query("DELETE FROM match_participants WHERE match_id = ?")
.bind(match_id)
.execute(&pool)
.await
.unwrap();
sqlx::query("DELETE FROM matches WHERE id = ?")
.bind(match_id)
.execute(&pool)
.await
.unwrap();
// Verify ratings are back to original
let final_rating_a: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
.bind(pa_id.0)
.fetch_one(&pool)
.await
.unwrap();
assert!((final_rating_a.0 - 1500.0).abs() < 0.01,
"Player A rating not reverted: {}", final_rating_a.0);
let final_rating_b: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
.bind(pb_id.0)
.fetch_one(&pool)
.await
.unwrap();
assert!((final_rating_b.0 - 1500.0).abs() < 0.01,
"Player B rating not reverted: {}", final_rating_b.0);
}
#[tokio::test]
async fn test_doubles_match_reversal() {
let pool = setup_test_db().await;
// Create four players
let players = [
("D1", 1500.0), ("D2", 1520.0), ("D3", 1480.0), ("D4", 1500.0)
];
for (name, rating) in &players {
sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)")
.bind(*name)
.bind(*rating)
.execute(&pool)
.await
.unwrap();
}
let d1_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = 'D1'")
.fetch_one(&pool).await.unwrap();
let d2_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = 'D2'")
.fetch_one(&pool).await.unwrap();
let d3_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = 'D3'")
.fetch_one(&pool).await.unwrap();
let d4_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = 'D4'")
.fetch_one(&pool).await.unwrap();
let d1_id = d1_id.0;
let d2_id = d2_id.0;
let d3_id = d3_id.0;
let d4_id = d4_id.0;
// Record doubles match
let match_result = sqlx::query(
"INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)"
)
.bind("doubles")
.bind(11)
.bind(8)
.execute(&pool)
.await
.unwrap();
let match_id = match_result.last_insert_rowid();
// Team 1 (D1 + D2) wins, Team 2 (D3 + D4) loses
// Each player has different rating change due to effective opponent
let changes = [
(d1_id, 1, 8.0),
(d2_id, 1, 5.0), // Stronger player gains less
(d3_id, 2, -7.0),
(d4_id, 2, -6.0),
];
for (player_id, team, change) in &changes {
let before = match *team {
1 => if *player_id == d1_id { 1500.0 } else { 1520.0 },
_ => if *player_id == d3_id { 1480.0 } else { 1500.0 },
};
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team, rating_before, rating_after, rating_change) VALUES (?, ?, ?, ?, ?, ?)"
)
.bind(match_id)
.bind(player_id)
.bind(team)
.bind(before)
.bind(before + change)
.bind(change)
.execute(&pool)
.await
.unwrap();
// Apply rating change
sqlx::query("UPDATE players SET singles_rating = singles_rating + ? WHERE id = ?")
.bind(change)
.bind(player_id)
.execute(&pool)
.await
.unwrap();
}
// Store original ratings for comparison
let original_ratings: Vec<(i64, f64)> = vec![
(d1_id, 1500.0), (d2_id, 1520.0), (d3_id, 1480.0), (d4_id, 1500.0)
];
// REVERSE THE MATCH
let participants: Vec<(i64, f64)> = sqlx::query_as(
"SELECT player_id, rating_change FROM match_participants WHERE match_id = ?"
)
.bind(match_id)
.fetch_all(&pool)
.await
.unwrap();
for (player_id, change) in participants {
sqlx::query("UPDATE players SET singles_rating = singles_rating - ? WHERE id = ?")
.bind(change)
.bind(player_id)
.execute(&pool)
.await
.unwrap();
}
sqlx::query("DELETE FROM match_participants WHERE match_id = ?")
.bind(match_id)
.execute(&pool)
.await
.unwrap();
sqlx::query("DELETE FROM matches WHERE id = ?")
.bind(match_id)
.execute(&pool)
.await
.unwrap();
// Verify all ratings reverted
for (player_id, original_rating) in original_ratings {
let final_rating: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
.bind(player_id)
.fetch_one(&pool)
.await
.unwrap();
assert!((final_rating.0 - original_rating).abs() < 0.01,
"Player {} rating not reverted: {} (expected {})",
player_id, final_rating.0, original_rating);
}
}
#[tokio::test]
async fn test_multiple_match_reversal() {
let pool = setup_test_db().await;
sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)")
.bind("Multi A")
.bind(1500.0)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)")
.bind("Multi B")
.bind(1500.0)
.execute(&pool)
.await
.unwrap();
let pa_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Multi A")
.fetch_one(&pool)
.await
.unwrap();
let pb_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Multi B")
.fetch_one(&pool)
.await
.unwrap();
// Record 5 matches
let mut match_ids = vec![];
let mut changes_a = vec![];
let mut changes_b = vec![];
for i in 0..5 {
let score1 = 11;
let score2 = 5 + i;
let change_a = 10.0 - i as f64;
let change_b = -(10.0 - i as f64);
let match_result = sqlx::query(
"INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)"
)
.bind("singles")
.bind(score1)
.bind(score2)
.execute(&pool)
.await
.unwrap();
let match_id = match_result.last_insert_rowid();
match_ids.push(match_id);
changes_a.push(change_a);
changes_b.push(change_b);
// Record participants
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)"
)
.bind(match_id)
.bind(pa_id.0)
.bind(1)
.bind(change_a)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)"
)
.bind(match_id)
.bind(pb_id.0)
.bind(2)
.bind(change_b)
.execute(&pool)
.await
.unwrap();
// Apply changes
sqlx::query("UPDATE players SET singles_rating = singles_rating + ? WHERE id = ?")
.bind(change_a)
.bind(pa_id.0)
.execute(&pool)
.await
.unwrap();
sqlx::query("UPDATE players SET singles_rating = singles_rating + ? WHERE id = ?")
.bind(change_b)
.bind(pb_id.0)
.execute(&pool)
.await
.unwrap();
}
// Verify ratings after 5 matches
// A gains: 10+9+8+7+6 = 40
// B loses: -40
let rating_a: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
.bind(pa_id.0)
.fetch_one(&pool)
.await
.unwrap();
assert!((rating_a.0 - 1540.0).abs() < 0.01);
// Delete matches in REVERSE order (LIFO - last in, first out)
for match_id in match_ids.iter().rev() {
let participants: Vec<(i64, f64)> = sqlx::query_as(
"SELECT player_id, rating_change FROM match_participants WHERE match_id = ?"
)
.bind(match_id)
.fetch_all(&pool)
.await
.unwrap();
for (player_id, change) in participants {
sqlx::query("UPDATE players SET singles_rating = singles_rating - ? WHERE id = ?")
.bind(change)
.bind(player_id)
.execute(&pool)
.await
.unwrap();
}
sqlx::query("DELETE FROM match_participants WHERE match_id = ?")
.bind(match_id)
.execute(&pool)
.await
.unwrap();
sqlx::query("DELETE FROM matches WHERE id = ?")
.bind(match_id)
.execute(&pool)
.await
.unwrap();
}
// Verify both players back to 1500
let final_a: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
.bind(pa_id.0)
.fetch_one(&pool)
.await
.unwrap();
assert!((final_a.0 - 1500.0).abs() < 0.01);
let final_b: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
.bind(pb_id.0)
.fetch_one(&pool)
.await
.unwrap();
assert!((final_b.0 - 1500.0).abs() < 0.01);
}
#[tokio::test]
async fn test_reversal_preserves_other_matches() {
let pool = setup_test_db().await;
// Create 3 players: A plays both B and C
for (name, rating) in [("Preserve A", 1500.0), ("Preserve B", 1500.0), ("Preserve C", 1500.0)] {
sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)")
.bind(name)
.bind(rating)
.execute(&pool)
.await
.unwrap();
}
let pa_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Preserve A")
.fetch_one(&pool)
.await
.unwrap();
let pb_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Preserve B")
.fetch_one(&pool)
.await
.unwrap();
let pc_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Preserve C")
.fetch_one(&pool)
.await
.unwrap();
// Match 1: A vs B (A wins)
let match1 = sqlx::query("INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)")
.bind("singles").bind(11).bind(5)
.execute(&pool).await.unwrap();
let match1_id = match1.last_insert_rowid();
sqlx::query("INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)")
.bind(match1_id).bind(pa_id.0).bind(1).bind(10.0)
.execute(&pool).await.unwrap();
sqlx::query("INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)")
.bind(match1_id).bind(pb_id.0).bind(2).bind(-10.0)
.execute(&pool).await.unwrap();
sqlx::query("UPDATE players SET singles_rating = singles_rating + 10 WHERE id = ?")
.bind(pa_id.0).execute(&pool).await.unwrap();
sqlx::query("UPDATE players SET singles_rating = singles_rating - 10 WHERE id = ?")
.bind(pb_id.0).execute(&pool).await.unwrap();
// Match 2: A vs C (A wins)
let match2 = sqlx::query("INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)")
.bind("singles").bind(11).bind(7)
.execute(&pool).await.unwrap();
let match2_id = match2.last_insert_rowid();
sqlx::query("INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)")
.bind(match2_id).bind(pa_id.0).bind(1).bind(8.0)
.execute(&pool).await.unwrap();
sqlx::query("INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)")
.bind(match2_id).bind(pc_id.0).bind(2).bind(-8.0)
.execute(&pool).await.unwrap();
sqlx::query("UPDATE players SET singles_rating = singles_rating + 8 WHERE id = ?")
.bind(pa_id.0).execute(&pool).await.unwrap();
sqlx::query("UPDATE players SET singles_rating = singles_rating - 8 WHERE id = ?")
.bind(pc_id.0).execute(&pool).await.unwrap();
// After both matches: A=1518, B=1490, C=1492
// Delete ONLY match 1 (A vs B)
let participants: Vec<(i64, f64)> = sqlx::query_as(
"SELECT player_id, rating_change FROM match_participants WHERE match_id = ?"
)
.bind(match1_id)
.fetch_all(&pool)
.await
.unwrap();
for (player_id, change) in participants {
sqlx::query("UPDATE players SET singles_rating = singles_rating - ? WHERE id = ?")
.bind(change)
.bind(player_id)
.execute(&pool)
.await
.unwrap();
}
sqlx::query("DELETE FROM match_participants WHERE match_id = ?")
.bind(match1_id)
.execute(&pool)
.await
.unwrap();
sqlx::query("DELETE FROM matches WHERE id = ?")
.bind(match1_id)
.execute(&pool)
.await
.unwrap();
// After reverting match 1: A=1508, B=1500, C=1492
// Match 2's effect on A and C should be preserved
let final_a: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
.bind(pa_id.0)
.fetch_one(&pool)
.await
.unwrap();
assert!((final_a.0 - 1508.0).abs() < 0.01, "A rating wrong: {}", final_a.0);
let final_b: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
.bind(pb_id.0)
.fetch_one(&pool)
.await
.unwrap();
assert!((final_b.0 - 1500.0).abs() < 0.01, "B rating wrong: {}", final_b.0);
let final_c: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
.bind(pc_id.0)
.fetch_one(&pool)
.await
.unwrap();
assert!((final_c.0 - 1492.0).abs() < 0.01, "C rating wrong: {}", final_c.0);
// Match 2 should still exist
let match_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM matches")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(match_count.0, 1);
}