Phase 7 (Core): chemistry v2, expanded card pool & search filters
CI / Build, lint & test (push) Failing after 1m46s

- Chemistry v2: FUT-style link scoring with per-player breakdown
  (club +3/link cap 6, league +1/link cap 4, nation +1/link cap 3;
   player cap 10, team cap 100); chemistry response now includes
   per-player breakdown with club/league/nation link counts and pts
- Card pool: 34 new cards across Premier League, La Liga, Bundesliga
  with overlapping clubs/nations for meaningful chemistry testing
- Card search: extended GET /cards with nation, league, club,
  min_overall, max_overall, limit query params; results sorted by
  overall descending
- Match opponent: added "ultimate" difficulty band (85+ OVR);
  random formation selection from 6 tactical formations; falls back
  to lower OVR pool when not enough cards at the requested band
- Tests: 9 new integration tests (28 total, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 16:31:56 -07:00
parent 1ef2c436ab
commit 8fa125cfd6
7 changed files with 867 additions and 77 deletions
+172
View File
@@ -0,0 +1,172 @@
[
{
"id": "card_bl_001",
"name": "Klaus Berger",
"overall": 84,
"position": "ST",
"nation": "Germany",
"league": "Bundesliga",
"club": "Röder FC",
"pace": 82,
"shooting": 87,
"passing": 72,
"dribbling": 80,
"defending": 36,
"physical": 84,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_002",
"name": "Felix Hartmann",
"overall": 83,
"position": "CM",
"nation": "Germany",
"league": "Bundesliga",
"club": "Röder FC",
"pace": 74,
"shooting": 74,
"passing": 86,
"dribbling": 80,
"defending": 72,
"physical": 76,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_003",
"name": "Matteo Ricci",
"overall": 82,
"position": "CAM",
"nation": "Italy",
"league": "Bundesliga",
"club": "Röder FC",
"pace": 76,
"shooting": 80,
"passing": 85,
"dribbling": 84,
"defending": 42,
"physical": 66,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_004",
"name": "Jan Schreiber",
"overall": 81,
"position": "CB",
"nation": "Germany",
"league": "Bundesliga",
"club": "Röder FC",
"pace": 74,
"shooting": 40,
"passing": 66,
"dribbling": 56,
"defending": 85,
"physical": 87,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_005",
"name": "Lucas Fonseca",
"overall": 83,
"position": "LW",
"nation": "Brazil",
"league": "Bundesliga",
"club": "Blau Stern München",
"pace": 90,
"shooting": 78,
"passing": 77,
"dribbling": 88,
"defending": 32,
"physical": 66,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_006",
"name": "Stefan Wolf",
"overall": 82,
"position": "CDM",
"nation": "Germany",
"league": "Bundesliga",
"club": "Blau Stern München",
"pace": 72,
"shooting": 64,
"passing": 78,
"dribbling": 74,
"defending": 85,
"physical": 82,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_007",
"name": "Marco Bauer",
"overall": 80,
"position": "RB",
"nation": "Germany",
"league": "Bundesliga",
"club": "Blau Stern München",
"pace": 82,
"shooting": 54,
"passing": 74,
"dribbling": 74,
"defending": 80,
"physical": 76,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_008",
"name": "Henri Delacroix",
"overall": 84,
"position": "ST",
"nation": "France",
"league": "Bundesliga",
"club": "Rhein United",
"pace": 86,
"shooting": 87,
"passing": 73,
"dribbling": 82,
"defending": 38,
"physical": 80,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_009",
"name": "Tobias Kohl",
"overall": 80,
"position": "GK",
"nation": "Germany",
"league": "Bundesliga",
"club": "Rhein United",
"pace": 56,
"shooting": 16,
"passing": 60,
"dribbling": 30,
"defending": 82,
"physical": 82,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_010",
"name": "Nicolas Braun",
"overall": 81,
"position": "LB",
"nation": "Germany",
"league": "Bundesliga",
"club": "Rhein United",
"pace": 78,
"shooting": 52,
"passing": 74,
"dribbling": 72,
"defending": 81,
"physical": 78,
"rarity": "gold",
"image_path": null
}
]
+206
View File
@@ -0,0 +1,206 @@
[
{
"id": "card_ll_001",
"name": "Carlos Mendez",
"overall": 85,
"position": "ST",
"nation": "Spain",
"league": "La Liga",
"club": "Atletico Fuego",
"pace": 86,
"shooting": 89,
"passing": 72,
"dribbling": 84,
"defending": 36,
"physical": 82,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_002",
"name": "Pablo Serrano",
"overall": 83,
"position": "CAM",
"nation": "Spain",
"league": "La Liga",
"club": "Atletico Fuego",
"pace": 78,
"shooting": 80,
"passing": 86,
"dribbling": 86,
"defending": 46,
"physical": 70,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_003",
"name": "Rodrigo Vidal",
"overall": 82,
"position": "CM",
"nation": "Spain",
"league": "La Liga",
"club": "Atletico Fuego",
"pace": 74,
"shooting": 70,
"passing": 84,
"dribbling": 80,
"defending": 72,
"physical": 74,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_004",
"name": "Andrés Torres",
"overall": 81,
"position": "CB",
"nation": "Spain",
"league": "La Liga",
"club": "Atletico Fuego",
"pace": 73,
"shooting": 38,
"passing": 68,
"dribbling": 58,
"defending": 84,
"physical": 86,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_005",
"name": "Gabriel Costa",
"overall": 84,
"position": "LW",
"nation": "Brazil",
"league": "La Liga",
"club": "Real Viento",
"pace": 92,
"shooting": 80,
"passing": 78,
"dribbling": 90,
"defending": 32,
"physical": 64,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_006",
"name": "Javier Ruiz",
"overall": 83,
"position": "CDM",
"nation": "Spain",
"league": "La Liga",
"club": "Real Viento",
"pace": 72,
"shooting": 62,
"passing": 80,
"dribbling": 76,
"defending": 86,
"physical": 82,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_007",
"name": "Miguel Ángel Prieto",
"overall": 80,
"position": "RB",
"nation": "Spain",
"league": "La Liga",
"club": "Real Viento",
"pace": 80,
"shooting": 52,
"passing": 76,
"dribbling": 74,
"defending": 80,
"physical": 74,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_008",
"name": "Diego Reyes",
"overall": 82,
"position": "ST",
"nation": "Argentina",
"league": "La Liga",
"club": "Valencia Azul",
"pace": 85,
"shooting": 84,
"passing": 70,
"dribbling": 82,
"defending": 36,
"physical": 78,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_009",
"name": "Marc Gutiérrez",
"overall": 80,
"position": "GK",
"nation": "Spain",
"league": "La Liga",
"club": "Valencia Azul",
"pace": 54,
"shooting": 14,
"passing": 62,
"dribbling": 28,
"defending": 81,
"physical": 80,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_010",
"name": "Emilio Navarro",
"overall": 81,
"position": "LB",
"nation": "Spain",
"league": "La Liga",
"club": "Valencia Azul",
"pace": 79,
"shooting": 52,
"passing": 74,
"dribbling": 72,
"defending": 82,
"physical": 76,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_011",
"name": "Thiago Drummond",
"overall": 83,
"position": "CB",
"nation": "Brazil",
"league": "La Liga",
"club": "Atletico Fuego",
"pace": 76,
"shooting": 38,
"passing": 68,
"dribbling": 62,
"defending": 86,
"physical": 88,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_012",
"name": "Fran Molina",
"overall": 82,
"position": "RW",
"nation": "Spain",
"league": "La Liga",
"club": "Real Viento",
"pace": 87,
"shooting": 76,
"passing": 76,
"dribbling": 85,
"defending": 34,
"physical": 66,
"rarity": "gold",
"image_path": null
}
]
+206
View File
@@ -0,0 +1,206 @@
[
{
"id": "card_pl_001",
"name": "James Caldwell",
"overall": 84,
"position": "ST",
"nation": "England",
"league": "Premier League",
"club": "Northgate United",
"pace": 84,
"shooting": 87,
"passing": 70,
"dribbling": 80,
"defending": 36,
"physical": 82,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_002",
"name": "Danny Walsh",
"overall": 82,
"position": "CM",
"nation": "England",
"league": "Premier League",
"club": "Northgate United",
"pace": 74,
"shooting": 72,
"passing": 84,
"dribbling": 79,
"defending": 70,
"physical": 74,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_003",
"name": "Reece Okafor",
"overall": 83,
"position": "LW",
"nation": "England",
"league": "Premier League",
"club": "Northgate United",
"pace": 91,
"shooting": 78,
"passing": 75,
"dribbling": 88,
"defending": 34,
"physical": 68,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_004",
"name": "Tom Briggs",
"overall": 80,
"position": "CB",
"nation": "England",
"league": "Premier League",
"club": "Northgate United",
"pace": 72,
"shooting": 36,
"passing": 62,
"dribbling": 52,
"defending": 83,
"physical": 86,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_005",
"name": "Pierre-Marc Dubois",
"overall": 84,
"position": "CAM",
"nation": "France",
"league": "Premier League",
"club": "Southfield City",
"pace": 79,
"shooting": 80,
"passing": 87,
"dribbling": 85,
"defending": 44,
"physical": 70,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_006",
"name": "Kwame Asante",
"overall": 85,
"position": "ST",
"nation": "Ghana",
"league": "Premier League",
"club": "Southfield City",
"pace": 88,
"shooting": 88,
"passing": 70,
"dribbling": 84,
"defending": 38,
"physical": 86,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_007",
"name": "Liam Foster",
"overall": 81,
"position": "CDM",
"nation": "England",
"league": "Premier League",
"club": "Southfield City",
"pace": 74,
"shooting": 62,
"passing": 78,
"dribbling": 74,
"defending": 84,
"physical": 80,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_008",
"name": "Sergio Rojas",
"overall": 83,
"position": "RW",
"nation": "Argentina",
"league": "Premier League",
"club": "Westbrook FC",
"pace": 89,
"shooting": 80,
"passing": 78,
"dribbling": 90,
"defending": 30,
"physical": 62,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_009",
"name": "Ben Archer",
"overall": 79,
"position": "GK",
"nation": "England",
"league": "Premier League",
"club": "Westbrook FC",
"pace": 52,
"shooting": 14,
"passing": 62,
"dribbling": 28,
"defending": 80,
"physical": 80,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_010",
"name": "Nathan Cross",
"overall": 80,
"position": "RB",
"nation": "England",
"league": "Premier League",
"club": "Westbrook FC",
"pace": 81,
"shooting": 50,
"passing": 74,
"dribbling": 72,
"defending": 80,
"physical": 76,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_011",
"name": "Marco Silva Jr.",
"overall": 83,
"position": "CB",
"nation": "Brazil",
"league": "Premier League",
"club": "Westbrook FC",
"pace": 77,
"shooting": 40,
"passing": 66,
"dribbling": 60,
"defending": 86,
"physical": 88,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_012",
"name": "Erik Strand",
"overall": 82,
"position": "LB",
"nation": "Norway",
"league": "Premier League",
"club": "Northgate United",
"pace": 80,
"shooting": 54,
"passing": 76,
"dribbling": 74,
"defending": 82,
"physical": 78,
"rarity": "gold",
"image_path": null
}
]
+38 -8
View File
@@ -16,6 +16,12 @@ use crate::{
pub struct CardQuery { pub struct CardQuery {
pub rarity: Option<String>, pub rarity: Option<String>,
pub position: Option<String>, pub position: Option<String>,
pub nation: Option<String>,
pub league: Option<String>,
pub club: Option<String>,
pub min_overall: Option<u8>,
pub max_overall: Option<u8>,
pub limit: Option<usize>,
} }
pub async fn get_card( pub async fn get_card(
@@ -33,25 +39,49 @@ pub async fn get_cards(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<CardQuery>, Query(query): Query<CardQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let cards = state let mut cards: Vec<_> = state
.card_db .card_db
.all() .all()
.into_iter() .into_iter()
.filter(|c| { .filter(|c| {
query let rarity_ok = query
.rarity .rarity
.as_ref() .as_ref()
.map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase()) .map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase())
.unwrap_or(true) .unwrap_or(true);
&& query let pos_ok = query
.position .position
.as_ref() .as_ref()
.map(|p| c.position.to_lowercase() == p.to_lowercase()) .map(|p| c.position.eq_ignore_ascii_case(p))
.unwrap_or(true) .unwrap_or(true);
let nation_ok = query
.nation
.as_ref()
.map(|n| c.nation.eq_ignore_ascii_case(n))
.unwrap_or(true);
let league_ok = query
.league
.as_ref()
.map(|l| c.league.eq_ignore_ascii_case(l))
.unwrap_or(true);
let club_ok = query
.club
.as_ref()
.map(|cl| c.club.eq_ignore_ascii_case(cl))
.unwrap_or(true);
let min_ok = query.min_overall.map(|m| c.overall >= m).unwrap_or(true);
let max_ok = query.max_overall.map(|m| c.overall <= m).unwrap_or(true);
rarity_ok && pos_ok && nation_ok && league_ok && club_ok && min_ok && max_ok
}) })
.collect::<Vec<_>>(); .collect();
Ok(Json(json!({ "cards": cards, "total": cards.len() }))) cards.sort_by_key(|c| std::cmp::Reverse(c.overall));
let total = cards.len();
if let Some(limit) = query.limit {
cards.truncate(limit);
}
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
} }
pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Value>> {
+33 -36
View File
@@ -9,73 +9,70 @@ use crate::{
}; };
use rand::{seq::SliceRandom, Rng}; use rand::{seq::SliceRandom, Rng};
const FORMATIONS: &[&str] = &[
"4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2",
];
/// Generate a random AI opponent squad for Squad Battles. /// Generate a random AI opponent squad for Squad Battles.
///
/// Difficulty bands:
/// beginner — overall 55+ (same as "any")
/// professional — overall 70+
/// world_class — overall 78+
/// legendary — overall 85+
/// ultimate — overall 90+
pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Value { pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Value {
let (min_overall, names): (u8, &[&str]) = match difficulty { let (min_overall, names): (u8, &[&str]) = match difficulty {
"professional" => ( "professional" => (
70, 70,
&[ &["Athletic CF", "City Wanderers", "The Rovers", "United Select", "Blue Stars FC"],
"Athletic CF",
"City Wanderers",
"The Rovers",
"United Select",
"Blue Stars FC",
],
), ),
"world_class" => ( "world_class" => (
78, 78,
&[ &["Elite Stars FC", "Champions Select", "Premier XI", "Galaxy United", "Titan FC"],
"Elite Stars FC",
"Champions Select",
"Premier XI",
"Galaxy United",
"Titan FC",
],
), ),
"legendary" => ( "legendary" => (
85, 85,
&[ &["Legends United", "Ultimate XI", "Gold Standard FC", "The Icons", "Heritage FC"],
"Legends United", ),
"Ultimate XI", "ultimate" => (
"Gold Standard FC", 90,
"The Icons", &["Apex XI", "Pantheon FC", "Gods of FUT", "Invincibles Select", "Eternal XI"],
"Heritage FC",
],
), ),
_ => ( _ => (
58, 55,
&[ &["Amateur Town FC", "Sunday League XI", "Park FC", "Village Stars", "Reserve XI"],
"Amateur Town FC",
"Sunday League XI",
"Park FC",
"Village Stars",
"Reserve XI",
],
), ),
}; };
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let all = card_db.by_min_overall(min_overall); let all = card_db.by_min_overall(min_overall);
let mut indices: Vec<usize> = (0..all.len()).collect();
// Fall back to lower overall band if not enough cards at this difficulty
let pool: Vec<_> = if all.len() >= 11 {
all
} else {
card_db.by_min_overall(55)
};
let mut indices: Vec<usize> = (0..pool.len()).collect();
indices.shuffle(&mut rng); indices.shuffle(&mut rng);
let cards: Vec<_> = indices let cards: Vec<_> = indices.into_iter().take(11).map(|i| pool[i].clone()).collect();
.into_iter()
.take(11)
.map(|i| all[i].clone())
.collect();
let squad_rating = if cards.is_empty() { let squad_rating = if cards.is_empty() {
0 0
} else { } else {
cards.iter().map(|c| c.overall as i64).sum::<i64>() / cards.len() as i64 cards.iter().map(|c| c.overall as i64).sum::<i64>() / cards.len() as i64
}; };
let name = names[rng.gen_range(0..names.len())]; let name = names[rng.gen_range(0..names.len())];
let formation = FORMATIONS[rng.gen_range(0..FORMATIONS.len())];
serde_json::json!({ serde_json::json!({
"opponent_name": name, "opponent_name": name,
"difficulty": difficulty, "difficulty": difficulty,
"squad_rating": squad_rating, "squad_rating": squad_rating,
"formation": "4-4-2", "formation": formation,
"cards": cards, "cards": cards,
}) })
} }
+59 -31
View File
@@ -105,6 +105,15 @@ pub async fn validate_formation(
Ok(()) Ok(())
} }
/// Chemistry v2: FUT-style link scoring.
///
/// Each player earns up to 10 chemistry from three link types:
/// - Club links: strongest signal — +3 per shared club teammate (max 6 pts)
/// - League links: medium signal — +1 per shared league mate (max 4 pts)
/// - Nation links: weakest signal — +1 per shared nation mate (max 3 pts)
///
/// Individual player chemistry is capped at 10.
/// Team chemistry = sum of all player chemistries, capped at 100.
pub async fn calculate_chemistry( pub async fn calculate_chemistry(
pool: &Pool, pool: &Pool,
card_db: &CardDb, card_db: &CardDb,
@@ -112,10 +121,12 @@ pub async fn calculate_chemistry(
) -> AppResult<serde_json::Value> { ) -> AppResult<serde_json::Value> {
let starters: Vec<&SquadPlayer> = players.iter().filter(|p| !p.is_on_bench).collect(); let starters: Vec<&SquadPlayer> = players.iter().filter(|p| !p.is_on_bench).collect();
let mut cards: Vec<CardDefinition> = Vec::new(); // Load all starter card definitions (N separate queries, fine for 11 players)
let mut player_cards: Vec<(String, CardDefinition)> = Vec::new();
for sp in &starters { for sp in &starters {
let owned = sqlx::query_as::<_, OwnedCard>( let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ?", "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
FROM owned_cards WHERE id = ?",
) )
.bind(&sp.owned_card_id) .bind(&sp.owned_card_id)
.fetch_optional(pool) .fetch_optional(pool)
@@ -123,43 +134,60 @@ pub async fn calculate_chemistry(
if let Some(o) = owned { if let Some(o) = owned {
if let Some(card) = card_db.get(&o.card_id) { if let Some(card) = card_db.get(&o.card_id) {
cards.push(card.clone()); player_cards.push((sp.owned_card_id.clone(), card.clone()));
} }
} }
} }
let player_chems: Vec<i64> = cards let mut player_chemistries = Vec::with_capacity(player_cards.len());
.iter() let mut total_chem: i64 = 0;
.enumerate()
.map(|(i, c)| {
let same_club = cards
.iter()
.enumerate()
.filter(|(j, o)| *j != i && o.club == c.club)
.count()
.min(4) as i64;
let same_league = cards
.iter()
.enumerate()
.filter(|(j, o)| *j != i && o.league == c.league)
.count()
.min(3) as i64;
let same_nation = cards
.iter()
.enumerate()
.filter(|(j, o)| *j != i && o.nation == c.nation)
.count()
.min(3) as i64;
(same_club + same_league + same_nation).min(10)
})
.collect();
let total: i64 = player_chems.iter().sum::<i64>().min(100); for (i, (owned_id, card)) in player_cards.iter().enumerate() {
let club_links = player_cards
.iter()
.enumerate()
.filter(|(j, (_, c))| *j != i && c.club == card.club)
.count() as i64;
let league_links = player_cards
.iter()
.enumerate()
.filter(|(j, (_, c))| *j != i && c.league == card.league)
.count() as i64;
let nation_links = player_cards
.iter()
.enumerate()
.filter(|(j, (_, c))| *j != i && c.nation == card.nation)
.count() as i64;
let club_pts = (club_links * 3).min(6);
let league_pts = league_links.min(4);
let nation_pts = nation_links.min(3);
let player_chem = (club_pts + league_pts + nation_pts).min(10);
total_chem += player_chem;
player_chemistries.push(serde_json::json!({
"owned_card_id": owned_id,
"card_id": &card.id,
"name": &card.name,
"chemistry": player_chem,
"breakdown": {
"club_links": club_links,
"league_links": league_links,
"nation_links": nation_links,
"club_pts": club_pts,
"league_pts": league_pts,
"nation_pts": nation_pts,
}
}));
}
let team_chemistry = total_chem.min(100);
Ok(serde_json::json!({ Ok(serde_json::json!({
"total": total, "total": team_chemistry,
"max": 100, "max": 100,
"player_chemistries": player_chems, "player_count": player_chemistries.len(),
"player_chemistries": player_chemistries,
})) }))
} }
+151
View File
@@ -482,3 +482,154 @@ async fn test_event_market_refresh_injects_totw_cards() {
"expected at least one [EVENT] market listing" "expected at least one [EVENT] market listing"
); );
} }
// ── Phase 7: Extended card search & chemistry ────────────────────────────────
#[tokio::test]
async fn test_cards_filter_by_league() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/cards?league=Premier+League").await;
assert_eq!(status, StatusCode::OK);
let cards = json["cards"].as_array().expect("cards array");
assert!(!cards.is_empty(), "Premier League cards should be loaded");
for card in cards {
assert_eq!(
card["league"].as_str().unwrap().to_lowercase(),
"premier league"
);
}
}
#[tokio::test]
async fn test_cards_filter_by_nation() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/cards?nation=Spain").await;
assert_eq!(status, StatusCode::OK);
let cards = json["cards"].as_array().expect("cards array");
assert!(!cards.is_empty(), "Spanish cards should exist in La Liga data");
for card in cards {
assert_eq!(
card["nation"].as_str().unwrap().to_lowercase(),
"spain"
);
}
}
#[tokio::test]
async fn test_cards_filter_by_min_overall() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/cards?min_overall=84").await;
assert_eq!(status, StatusCode::OK);
let cards = json["cards"].as_array().expect("cards array");
assert!(!cards.is_empty());
for card in cards {
assert!(
card["overall"].as_u64().unwrap() >= 84,
"card below min_overall threshold"
);
}
}
#[tokio::test]
async fn test_cards_filter_by_club() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/cards?club=Northgate+United").await;
assert_eq!(status, StatusCode::OK);
let cards = json["cards"].as_array().expect("cards array");
assert!(!cards.is_empty(), "Northgate United cards expected");
for card in cards {
assert_eq!(
card["club"].as_str().unwrap().to_lowercase(),
"northgate united"
);
}
}
#[tokio::test]
async fn test_cards_filter_combined_params() {
let app = build_test_app().await;
// La Liga cards with overall >= 82 — should include several
let (status, json) = json_get(&app, "/cards?league=La+Liga&min_overall=82").await;
assert_eq!(status, StatusCode::OK);
let cards = json["cards"].as_array().expect("cards array");
assert!(!cards.is_empty(), "combined filter should return results");
for card in cards {
assert_eq!(card["league"].as_str().unwrap().to_lowercase(), "la liga");
assert!(card["overall"].as_u64().unwrap() >= 82);
}
// total reflects untruncated count, returned reflects actual
assert_eq!(json["total"], json["returned"]);
}
#[tokio::test]
async fn test_cards_limit_parameter() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/cards?limit=3").await;
assert_eq!(status, StatusCode::OK);
let returned = json["returned"].as_u64().unwrap();
let total = json["total"].as_u64().unwrap();
assert!(returned <= 3);
assert!(total >= returned, "total should not be less than returned");
// Cards should be sorted by overall descending
let cards = json["cards"].as_array().unwrap();
for w in cards.windows(2) {
assert!(
w[0]["overall"].as_u64().unwrap() >= w[1]["overall"].as_u64().unwrap(),
"cards should be sorted by overall descending"
);
}
}
#[tokio::test]
async fn test_cards_max_overall_boundary() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/cards?max_overall=70").await;
assert_eq!(status, StatusCode::OK);
let cards = json["cards"].as_array().expect("cards array");
// We don't require results (there may be none), just verify none exceed limit
for card in cards {
assert!(
card["overall"].as_u64().unwrap() <= 70,
"card above max_overall threshold"
);
}
}
#[tokio::test]
async fn test_draft_squad_ultimate_difficulty() {
let app = build_test_app().await;
auth(&app, "UltimateDrafter").await;
let (status, json) = json_get(&app, "/draft/squad?difficulty=ultimate").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["difficulty"], "ultimate");
assert!(json["cards"].is_array());
// Should fall back to lower band if not enough 90+ cards — just verify 11 or fewer cards
let card_count = json["cards"].as_array().unwrap().len();
assert!(card_count <= 11);
}
#[tokio::test]
async fn test_chemistry_response_has_breakdown_structure() {
let app = build_test_app().await;
auth(&app, "ChemPlayer").await;
// Create an empty squad (no players)
let (s, j) = json_post(
&app,
"/squad",
serde_json::json!({ "name": "ChemSquad", "formation": "4-3-3", "players": [] }),
)
.await;
assert_eq!(s, StatusCode::OK, "{j}");
let squad_id = j["squad"]["id"].as_str().unwrap().to_string();
let (status, json) = json_get(&app, &format!("/squads/{squad_id}")).await;
assert_eq!(status, StatusCode::OK);
// Chemistry block should always be present
let chem = &json["chemistry"];
assert!(chem.is_object(), "chemistry should be an object");
assert_eq!(chem["total"], 0, "empty squad has 0 chemistry");
assert_eq!(chem["max"], 100);
assert!(chem["player_chemistries"].is_array());
}