Skip to main content

wc_data/backends/
espn.rs

1//! ESPN hidden-API backend (default).
2//!
3//! Endpoints (base `https://site.api.espn.com`, league `soccer/fifa.world`):
4//! - `/apis/site/v2/sports/soccer/fifa.world/scoreboard` — fixtures + live state
5//! - `/apis/site/v2/sports/soccer/fifa.world/summary?event={id}` — match detail
6//! - `/apis/v2/sports/soccer/fifa.world/standings` — group tables
7
8use std::collections::HashMap;
9
10use serde::Deserialize;
11use time::{Date, macros::format_description};
12
13use crate::backends::common::{
14    f64_to_i16, f64_to_u8, f64_to_u16, group_from_text, minute_from_clock, parse_time,
15    parse_u8_str, stage_for_date, stage_from_label, stage_from_slug,
16};
17use crate::domain::{
18    Bracket, BracketRound, Calendar, Group, GroupStanding, Lineup, Match, MatchDetail, MatchEvent,
19    MatchEventKind, MatchStatus, Player, Score, Stage, StageWindow, Team, TeamStat,
20};
21use crate::error::Result;
22use crate::provider::ScoreProvider;
23use crate::transport::Http;
24
25const SCOREBOARD_URL: &str =
26    "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard";
27const STANDINGS_URL: &str = "https://site.api.espn.com/apis/v2/sports/soccer/fifa.world/standings";
28/// Inclusive date window covering the entire 2026 FIFA World Cup (UTC, `YYYYMMDD`).
29///
30/// ESPN's scoreboard defaults to the current matchday only; passing this range
31/// returns every fixture (group stage through the final) in a single request so
32/// the schedule, live, bracket, and team views all have the full tournament.
33const TOURNAMENT_DATE_RANGE: &str = "20260611-20260719";
34/// Upper bound on events returned for the full-tournament query (104 matches in 2026).
35const SCHEDULE_LIMIT: u32 = 300;
36const SUMMARY_URL: &str =
37    "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/summary?event=";
38
39/// ESPN-backed provider.
40#[derive(Debug, Clone)]
41pub struct EspnProvider {
42    http: Http,
43}
44
45impl EspnProvider {
46    /// Build the ESPN provider over a shared HTTP client.
47    #[must_use]
48    pub fn new(http: Http) -> Self {
49        Self { http }
50    }
51
52    async fn fetch_scoreboard(&self, day: Option<Date>) -> Result<EspnScoreboard> {
53        let url = day.map_or_else(
54            || SCOREBOARD_URL.to_owned(),
55            |date| {
56                let fmt = format_description!("[year][month][day]");
57                let dates = date.format(fmt).unwrap_or_default();
58                format!("{SCOREBOARD_URL}?dates={dates}")
59            },
60        );
61        self.http.get_json(&url, &[]).await
62    }
63
64    /// Fetch every fixture in the tournament in one request. ESPN's default
65    /// scoreboard only returns the current matchday, so the full schedule (used
66    /// by the matches, live, bracket, and team views) needs an explicit date
67    /// range. Note the range response omits the league calendar, so stages are
68    /// resolved from each event's `season.slug` rather than the calendar.
69    async fn fetch_full_schedule(&self) -> Result<EspnScoreboard> {
70        let url = format!("{SCOREBOARD_URL}?dates={TOURNAMENT_DATE_RANGE}&limit={SCHEDULE_LIMIT}");
71        self.http.get_json(&url, &[]).await
72    }
73}
74
75impl ScoreProvider for EspnProvider {
76    fn name(&self) -> &'static str {
77        "ESPN"
78    }
79
80    async fn calendar(&self) -> Result<Calendar> {
81        self.fetch_scoreboard(None).await?.calendar()
82    }
83
84    async fn scoreboard(&self, day: Option<Date>) -> Result<Vec<Match>> {
85        let dto = match day {
86            Some(_) => self.fetch_scoreboard(day).await?,
87            None => self.fetch_full_schedule().await?,
88        };
89        let calendar = dto.calendar()?;
90        dto.matches(&calendar)
91    }
92
93    async fn standings(&self) -> Result<Vec<Group>> {
94        let dto: EspnStandings = self.http.get_json(STANDINGS_URL, &[]).await?;
95        Ok(dto.groups())
96    }
97
98    async fn bracket(&self) -> Result<Bracket> {
99        let dto = self.fetch_full_schedule().await?;
100        let calendar = dto.calendar()?;
101        let matches = dto.matches(&calendar)?;
102        let mut rounds = Vec::new();
103        for stage in Stage::knockout_order() {
104            let round_matches = matches
105                .iter()
106                .filter(|m| m.stage == stage)
107                .cloned()
108                .collect();
109            rounds.push(BracketRound {
110                stage,
111                matches: round_matches,
112            });
113        }
114        Ok(Bracket { rounds })
115    }
116
117    async fn match_detail(&self, id: &str) -> Result<MatchDetail> {
118        let url = format!("{SUMMARY_URL}{id}");
119        let dto: EspnSummary = self.http.get_json(&url, &[]).await?;
120        dto.detail()
121    }
122}
123
124#[derive(Debug, Deserialize)]
125#[serde(rename_all = "camelCase")]
126struct EspnScoreboard {
127    leagues: Vec<EspnLeague>,
128    #[serde(default)]
129    events: Vec<EspnEvent>,
130}
131
132impl EspnScoreboard {
133    fn calendar(&self) -> Result<Calendar> {
134        let stages = self
135            .leagues
136            .first()
137            .into_iter()
138            .flat_map(|league| &league.calendar)
139            .flat_map(|entry| {
140                if entry.entries.is_empty() {
141                    vec![entry]
142                } else {
143                    entry.entries.iter().collect()
144                }
145            })
146            .filter_map(|entry| {
147                let stage = stage_from_label(&entry.label)?;
148                Some((stage, entry))
149            })
150            .map(|(stage, entry)| {
151                Ok(StageWindow {
152                    stage,
153                    label: entry.label.clone(),
154                    start: parse_time(&entry.start_date)?,
155                    end: parse_time(&entry.end_date)?,
156                })
157            })
158            .collect::<Result<Vec<_>>>()?;
159        Ok(Calendar { stages })
160    }
161
162    fn matches(&self, calendar: &Calendar) -> Result<Vec<Match>> {
163        self.events
164            .iter()
165            .map(|event| event.to_match(calendar))
166            .collect()
167    }
168}
169
170#[derive(Debug, Deserialize)]
171#[serde(rename_all = "camelCase")]
172struct EspnLeague {
173    #[serde(default)]
174    calendar: Vec<EspnCalendarEntry>,
175}
176
177#[derive(Debug, Deserialize)]
178#[serde(rename_all = "camelCase")]
179struct EspnCalendarEntry {
180    label: String,
181    start_date: String,
182    end_date: String,
183    #[serde(default)]
184    entries: Vec<EspnCalendarEntry>,
185}
186
187#[derive(Debug, Deserialize)]
188#[serde(rename_all = "camelCase")]
189struct EspnEvent {
190    id: String,
191    date: String,
192    name: Option<String>,
193    season: Option<EspnSeason>,
194    #[serde(default)]
195    competitions: Vec<EspnCompetition>,
196}
197
198#[derive(Debug, Deserialize)]
199struct EspnSeason {
200    slug: Option<String>,
201}
202
203impl EspnEvent {
204    fn to_match(&self, calendar: &Calendar) -> Result<Match> {
205        let kickoff = parse_time(&self.date)?;
206        let competition = self.competitions.first();
207        let home = competition
208            .and_then(|c| c.competitor("home"))
209            .map_or_else(|| Team::placeholder("TBD"), EspnCompetitor::team);
210        let away = competition
211            .and_then(|c| c.competitor("away"))
212            .map_or_else(|| Team::placeholder("TBD"), EspnCompetitor::team);
213        let score = competition.and_then(EspnCompetition::score);
214        let status = competition.map_or(MatchStatus::Scheduled, EspnCompetition::status);
215        let note = competition
216            .and_then(|c| c.alt_game_note.as_deref())
217            .or(self.name.as_deref());
218        let stage = self
219            .season
220            .as_ref()
221            .and_then(|s| s.slug.as_deref())
222            .and_then(stage_from_slug)
223            .unwrap_or_else(|| {
224                let fallback =
225                    stage_from_label(note.unwrap_or_default()).unwrap_or(Stage::GroupStage);
226                stage_for_date(calendar, kickoff, fallback)
227            });
228        Ok(Match {
229            id: self.id.clone(),
230            stage,
231            group: group_from_text(note),
232            home,
233            away,
234            score,
235            status,
236            kickoff,
237            venue: competition
238                .and_then(|c| c.venue.as_ref())
239                .and_then(|v| v.full_name.clone()),
240        })
241    }
242}
243
244#[derive(Clone, Debug, Deserialize)]
245#[serde(rename_all = "camelCase")]
246struct EspnCompetition {
247    date: Option<String>,
248    #[serde(default)]
249    competitors: Vec<EspnCompetitor>,
250    status: Option<EspnStatus>,
251    venue: Option<EspnVenue>,
252    alt_game_note: Option<String>,
253}
254
255impl EspnCompetition {
256    fn competitor(&self, home_away: &str) -> Option<&EspnCompetitor> {
257        self.competitors
258            .iter()
259            .find(|c| c.home_away.as_deref() == Some(home_away))
260    }
261
262    fn score(&self) -> Option<Score> {
263        let status = self.status.as_ref()?;
264        if status
265            .type_
266            .as_ref()
267            .is_some_and(|t| t.state.as_deref() == Some("pre"))
268        {
269            return None;
270        }
271        Some(Score {
272            home: parse_u8_str(self.competitor("home").and_then(|c| c.score.as_deref()))
273                .unwrap_or_default(),
274            away: parse_u8_str(self.competitor("away").and_then(|c| c.score.as_deref()))
275                .unwrap_or_default(),
276            home_pens: None,
277            away_pens: None,
278        })
279    }
280
281    fn status(&self) -> MatchStatus {
282        self.status
283            .as_ref()
284            .map_or(MatchStatus::Unknown, EspnStatus::to_domain)
285    }
286}
287
288#[derive(Clone, Debug, Deserialize)]
289#[serde(rename_all = "camelCase")]
290struct EspnCompetitor {
291    home_away: Option<String>,
292    score: Option<String>,
293    team: Option<EspnTeam>,
294}
295
296impl EspnCompetitor {
297    fn team(&self) -> Team {
298        self.team
299            .as_ref()
300            .map_or_else(|| Team::placeholder("TBD"), EspnTeam::to_domain)
301    }
302}
303
304#[derive(Clone, Debug, Deserialize)]
305#[serde(rename_all = "camelCase")]
306struct EspnTeam {
307    id: Option<String>,
308    display_name: Option<String>,
309    abbreviation: Option<String>,
310    logo: Option<String>,
311    #[serde(default)]
312    logos: Vec<EspnLogo>,
313}
314
315impl EspnTeam {
316    fn to_domain(&self) -> Team {
317        let name = self
318            .display_name
319            .clone()
320            .unwrap_or_else(|| "TBD".to_owned());
321        let abbreviation = self
322            .abbreviation
323            .clone()
324            .unwrap_or_else(|| name.chars().take(3).collect::<String>().to_uppercase());
325        Team {
326            id: self.id.clone().unwrap_or_default(),
327            name,
328            abbreviation,
329            country_code: self.abbreviation.clone(),
330            crest_url: self
331                .logo
332                .clone()
333                .or_else(|| self.logos.first().map(|logo| logo.href.clone())),
334        }
335    }
336}
337
338#[derive(Clone, Debug, Deserialize)]
339struct EspnLogo {
340    href: String,
341}
342
343#[derive(Clone, Debug, Deserialize)]
344#[serde(rename_all = "camelCase")]
345struct EspnVenue {
346    full_name: Option<String>,
347}
348
349#[derive(Clone, Debug, Deserialize)]
350struct EspnStatus {
351    #[serde(rename = "type")]
352    type_: Option<EspnStatusType>,
353    display_clock: Option<String>,
354}
355
356impl EspnStatus {
357    fn to_domain(&self) -> MatchStatus {
358        let detail = self.type_.as_ref().and_then(|t| t.detail.clone());
359        match self.type_.as_ref().and_then(|t| t.state.as_deref()) {
360            Some("pre") => MatchStatus::Scheduled,
361            Some("in") => {
362                let desc = self
363                    .type_
364                    .as_ref()
365                    .and_then(|t| t.description.as_deref())
366                    .unwrap_or_default()
367                    .to_ascii_lowercase();
368                if desc.contains("half") && desc.contains("time") {
369                    MatchStatus::HalfTime
370                } else {
371                    MatchStatus::Live {
372                        minute: minute_from_clock(self.display_clock.as_deref())
373                            .or_else(|| minute_from_clock(detail.as_deref())),
374                        detail,
375                    }
376                }
377            }
378            Some("post") => {
379                let desc = self
380                    .type_
381                    .as_ref()
382                    .and_then(|t| t.description.as_deref())
383                    .unwrap_or_default()
384                    .to_ascii_lowercase();
385                if desc.contains("pen") {
386                    MatchStatus::Penalties
387                } else if desc.contains("extra") {
388                    MatchStatus::AfterExtraTime
389                } else {
390                    MatchStatus::FullTime
391                }
392            }
393            _ => MatchStatus::Unknown,
394        }
395    }
396}
397
398#[derive(Clone, Debug, Deserialize)]
399struct EspnStatusType {
400    state: Option<String>,
401    description: Option<String>,
402    detail: Option<String>,
403}
404
405#[derive(Debug, Deserialize)]
406struct EspnStandings {
407    #[serde(default)]
408    children: Vec<EspnStandingChild>,
409}
410
411impl EspnStandings {
412    fn groups(&self) -> Vec<Group> {
413        self.children
414            .iter()
415            .map(|child| Group {
416                name: child
417                    .name
418                    .strip_prefix("Group ")
419                    .unwrap_or(&child.name)
420                    .to_owned(),
421                standings: child
422                    .standings
423                    .entries
424                    .iter()
425                    .map(EspnStandingEntry::to_domain)
426                    .collect(),
427            })
428            .collect()
429    }
430}
431
432#[derive(Debug, Deserialize)]
433struct EspnStandingChild {
434    name: String,
435    standings: EspnStandingEntries,
436}
437
438#[derive(Debug, Deserialize)]
439struct EspnStandingEntries {
440    #[serde(default)]
441    entries: Vec<EspnStandingEntry>,
442}
443
444#[derive(Debug, Deserialize)]
445struct EspnStandingEntry {
446    team: EspnTeam,
447    #[serde(default)]
448    stats: Vec<EspnStat>,
449}
450
451impl EspnStandingEntry {
452    fn stat(&self, names: &[&str]) -> Option<f64> {
453        self.stats
454            .iter()
455            .find(|s| {
456                names
457                    .iter()
458                    .any(|name| s.name == *name || s.type_.as_deref() == Some(*name))
459            })
460            .and_then(|s| s.value)
461    }
462
463    fn to_domain(&self) -> GroupStanding {
464        GroupStanding {
465            team: self.team.to_domain(),
466            rank: f64_to_u8(self.stat(&["rank"])),
467            played: f64_to_u8(self.stat(&["gamesPlayed", "gamesplayed"])),
468            won: f64_to_u8(self.stat(&["wins"])),
469            drawn: f64_to_u8(self.stat(&["ties"])),
470            lost: f64_to_u8(self.stat(&["losses"])),
471            goals_for: f64_to_u16(self.stat(&["pointsFor", "pointsfor"])),
472            goals_against: f64_to_u16(self.stat(&["pointsAgainst", "pointsagainst"])),
473            goal_diff: f64_to_i16(self.stat(&["pointDifferential", "pointdifferential"])),
474            points: f64_to_u16(self.stat(&["points"])),
475        }
476    }
477}
478
479#[derive(Debug, Deserialize)]
480struct EspnStat {
481    name: String,
482    #[serde(rename = "type")]
483    type_: Option<String>,
484    value: Option<f64>,
485    #[serde(default, rename = "displayValue")]
486    display_value: String,
487    #[serde(default)]
488    label: String,
489}
490
491#[derive(Debug, Deserialize)]
492struct EspnSummary {
493    header: EspnHeader,
494    #[serde(default, rename = "keyEvents")]
495    key_events: Vec<EspnKeyEvent>,
496    #[serde(default)]
497    rosters: Vec<EspnRoster>,
498    boxscore: Option<EspnBoxscore>,
499}
500
501impl EspnSummary {
502    fn detail(&self) -> Result<MatchDetail> {
503        let calendar = Calendar::default();
504        let event = self.header.to_event();
505        let summary = event.to_match(&calendar)?;
506        Ok(MatchDetail {
507            summary,
508            events: self
509                .key_events
510                .iter()
511                .filter_map(EspnKeyEvent::to_domain)
512                .collect(),
513            lineups: self.rosters.iter().map(EspnRoster::to_domain).collect(),
514            stats: self
515                .boxscore
516                .as_ref()
517                .map_or_else(Vec::new, EspnBoxscore::team_stats),
518        })
519    }
520}
521
522#[derive(Debug, Deserialize)]
523struct EspnHeader {
524    id: String,
525    competitions: Vec<EspnCompetition>,
526}
527
528impl EspnHeader {
529    fn to_event(&self) -> EspnEvent {
530        let date = self
531            .competitions
532            .first()
533            .and_then(|c| c.date.clone())
534            .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_owned());
535        EspnEvent {
536            id: self.id.clone(),
537            date,
538            name: None,
539            season: None,
540            competitions: self.competitions.clone(),
541        }
542    }
543}
544
545#[derive(Debug, Deserialize)]
546struct EspnKeyEvent {
547    #[serde(rename = "type")]
548    type_: Option<EspnEventType>,
549    text: Option<String>,
550    clock: Option<EspnClock>,
551    team: Option<EspnEventTeam>,
552    #[serde(default)]
553    participants: Vec<EspnParticipant>,
554}
555
556impl EspnKeyEvent {
557    fn to_domain(&self) -> Option<MatchEvent> {
558        let kind = event_kind(
559            self.type_
560                .as_ref()
561                .and_then(|t| t.text.as_deref())
562                .or_else(|| self.type_.as_ref().and_then(|t| t.type_.as_deref()))?,
563        );
564        let player = self
565            .participants
566            .first()
567            .and_then(|p| p.athlete.display_name.clone());
568        Some(MatchEvent {
569            minute: self.clock.as_ref().and_then(EspnClock::minute),
570            stoppage: self.clock.as_ref().and_then(EspnClock::stoppage),
571            kind,
572            team_id: self.team.as_ref().and_then(|t| t.id.clone()),
573            player,
574            detail: self.text.clone(),
575        })
576    }
577}
578
579#[derive(Debug, Deserialize)]
580struct EspnEventType {
581    text: Option<String>,
582    #[serde(rename = "type")]
583    type_: Option<String>,
584}
585
586#[derive(Debug, Deserialize)]
587struct EspnClock {
588    value: Option<f64>,
589    #[serde(rename = "displayValue")]
590    display_value: Option<String>,
591}
592
593impl EspnClock {
594    fn minute(&self) -> Option<u16> {
595        minute_from_clock(self.display_value.as_deref())
596            .or_else(|| self.value.map(|v| (v / 60.0).floor() as u16))
597    }
598
599    fn stoppage(&self) -> Option<u16> {
600        let text = self.display_value.as_deref()?;
601        let plus = text.find('+')?;
602        let digits: String = text[plus + 1..]
603            .chars()
604            .take_while(char::is_ascii_digit)
605            .collect();
606        digits.parse().ok()
607    }
608}
609
610#[derive(Debug, Deserialize)]
611struct EspnEventTeam {
612    id: Option<String>,
613}
614
615#[derive(Debug, Deserialize)]
616struct EspnParticipant {
617    athlete: EspnAthlete,
618}
619
620#[derive(Debug, Deserialize)]
621struct EspnAthlete {
622    #[serde(rename = "displayName")]
623    display_name: Option<String>,
624    #[serde(rename = "fullName")]
625    full_name: Option<String>,
626}
627
628#[derive(Debug, Deserialize)]
629struct EspnRoster {
630    team: EspnTeam,
631    #[serde(default)]
632    roster: Vec<EspnRosterPlayer>,
633    formation: Option<String>,
634}
635
636impl EspnRoster {
637    fn to_domain(&self) -> Lineup {
638        let team = self.team.to_domain();
639        let mut starters = Vec::new();
640        let mut substitutes = Vec::new();
641        for player in &self.roster {
642            if player.starter.unwrap_or_default() {
643                starters.push(player.to_domain());
644            } else {
645                substitutes.push(player.to_domain());
646            }
647        }
648        Lineup {
649            team_id: team.id,
650            formation: self.formation.clone(),
651            starters,
652            substitutes,
653        }
654    }
655}
656
657#[derive(Debug, Deserialize)]
658struct EspnRosterPlayer {
659    starter: Option<bool>,
660    jersey: Option<String>,
661    athlete: EspnAthlete,
662    position: Option<EspnPosition>,
663}
664
665impl EspnRosterPlayer {
666    fn to_domain(&self) -> Player {
667        Player {
668            name: self
669                .athlete
670                .display_name
671                .clone()
672                .or_else(|| self.athlete.full_name.clone())
673                .unwrap_or_default(),
674            number: parse_u8_str(self.jersey.as_deref()),
675            position: self.position.as_ref().and_then(|p| p.abbreviation.clone()),
676        }
677    }
678}
679
680#[derive(Debug, Deserialize)]
681struct EspnPosition {
682    abbreviation: Option<String>,
683}
684
685#[derive(Debug, Deserialize)]
686struct EspnBoxscore {
687    #[serde(default)]
688    teams: Vec<EspnBoxTeam>,
689}
690
691impl EspnBoxscore {
692    fn team_stats(&self) -> Vec<TeamStat> {
693        if self.teams.len() < 2 {
694            return Vec::new();
695        }
696        let home = self
697            .teams
698            .iter()
699            .find(|t| t.home_away.as_deref() == Some("home"))
700            .unwrap_or(&self.teams[0]);
701        let away = self
702            .teams
703            .iter()
704            .find(|t| t.home_away.as_deref() == Some("away"))
705            .unwrap_or(&self.teams[1]);
706        let away_by_name: HashMap<&str, &EspnStat> = away
707            .statistics
708            .iter()
709            .map(|s| (s.name.as_str(), s))
710            .collect();
711        home.statistics
712            .iter()
713            .filter_map(|stat| {
714                let away_stat = away_by_name.get(stat.name.as_str())?;
715                Some(TeamStat {
716                    label: if stat.label.is_empty() {
717                        stat.name.clone()
718                    } else {
719                        stat.label.clone()
720                    },
721                    home: stat.display_value.clone(),
722                    away: away_stat.display_value.clone(),
723                })
724            })
725            .collect()
726    }
727}
728
729#[derive(Debug, Deserialize)]
730struct EspnBoxTeam {
731    #[serde(rename = "homeAway")]
732    home_away: Option<String>,
733    #[serde(default)]
734    statistics: Vec<EspnStat>,
735}
736
737fn event_kind(text: &str) -> MatchEventKind {
738    let lower = text.to_ascii_lowercase();
739    if lower.contains("own") {
740        MatchEventKind::OwnGoal
741    } else if lower.contains("penalty") && lower.contains("miss") {
742        MatchEventKind::PenaltyMiss
743    } else if lower.contains("penalty") {
744        MatchEventKind::PenaltyGoal
745    } else if lower.contains("goal") {
746        MatchEventKind::Goal
747    } else if lower.contains("second") && lower.contains("yellow") {
748        MatchEventKind::SecondYellow
749    } else if lower.contains("yellow") {
750        MatchEventKind::YellowCard
751    } else if lower.contains("red") {
752        MatchEventKind::RedCard
753    } else if lower.contains("sub") {
754        MatchEventKind::Substitution
755    } else if lower.contains("var") {
756        MatchEventKind::Var
757    } else {
758        MatchEventKind::Other
759    }
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765    use crate::error::DataError;
766
767    #[test]
768    fn maps_scoreboard_fixture() -> Result<()> {
769        let dto: EspnScoreboard =
770            serde_json::from_str(include_str!("../../tests/fixtures/espn_scoreboard.json"))
771                .map_err(|e| DataError::Decode(e.to_string()))?;
772        let calendar = dto.calendar()?;
773        let matches = dto.matches(&calendar)?;
774        assert_eq!(calendar.stages.len(), 7);
775        assert_eq!(matches.len(), 2);
776        assert_eq!(matches[0].home.name, "Canada");
777        assert!(matches[0].status.is_live());
778        assert_eq!(matches[0].group.as_deref(), Some("B"));
779        Ok(())
780    }
781
782    #[test]
783    fn maps_standings_fixture() -> Result<()> {
784        let dto: EspnStandings =
785            serde_json::from_str(include_str!("../../tests/fixtures/espn_standings.json"))
786                .map_err(|e| DataError::Decode(e.to_string()))?;
787        let groups = dto.groups();
788        assert_eq!(groups.len(), 1);
789        assert_eq!(groups[0].standings[0].team.name, "Mexico");
790        assert_eq!(groups[0].standings[0].points, 3);
791        Ok(())
792    }
793
794    #[test]
795    fn maps_summary_fixture() -> Result<()> {
796        let dto: EspnSummary =
797            serde_json::from_str(include_str!("../../tests/fixtures/espn_summary.json"))
798                .map_err(|e| DataError::Decode(e.to_string()))?;
799        let detail = dto.detail()?;
800        assert_eq!(detail.events[0].kind, MatchEventKind::YellowCard);
801        assert_eq!(
802            detail.events[0].player.as_deref(),
803            Some("Alistair Johnston")
804        );
805        assert_eq!(detail.lineups.len(), 2);
806        assert!(!detail.stats.is_empty());
807        Ok(())
808    }
809}