Coverage for src/usls/adapters/inbound/formatters.py: 98%
110 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-05 10:11 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-05 10:11 +0000
1"""Pure formatters that turn domain models into LLM-readable text.
3Extracted from mcp_adapter.py so the adapter focuses on tool wiring while the
4presentation layer stays a side-effect-free, easily testable concern.
5"""
7from ...domain.models import (
8 AdjustedPointsPerGame,
9 Match,
10 MatchCompetitor,
11 MatchDetails,
12 MatchEvent,
13 NewsArticle,
14 OpponentPPG,
15 Player,
16 ResultsByOpponentTier,
17 Standing,
18 StrengthOfSchedule,
19 Team,
20 TierRecord,
21)
24def _fmt_team(team: Team) -> str:
25 """Format a single Team as a labeled key-value block."""
26 lines = [
27 f"ID: {team.id}",
28 f"Name: {team.display_name}",
29 f"Abbreviation: {team.abbreviation}",
30 f"Location: {team.location}",
31 ]
32 if team.logo_url:
33 lines.append(f"Logo: {team.logo_url}")
34 return "\n".join(lines)
37def _fmt_teams(teams: list[Team]) -> str:
38 """Format a list of teams as a numbered list."""
39 if not teams:
40 return "No teams found."
41 entries = [f"{i}. {t.display_name} ({t.abbreviation}) — ID: {t.id}" for i, t in enumerate(teams, 1)]
42 return "\n".join(entries)
45def _fmt_competitor(comp: MatchCompetitor) -> str:
46 """Format one side of a match: team name, score, and home/away label."""
47 score_str = f" {comp.score}" if comp.score is not None else ""
48 winner_str = " ✓" if comp.winner else ""
49 return f"{comp.team.display_name}{score_str}{winner_str} ({comp.home_away})"
52def _fmt_match(match: Match) -> str:
53 """Format a single Match as a readable summary."""
54 competitor_lines = "\n ".join(_fmt_competitor(c) for c in match.competitors)
55 return (
56 f"Match: {match.name}\n"
57 f" ID: {match.id}\n"
58 f" Date: {match.date}\n"
59 f" Status: {match.status_detail}\n"
60 f" Competitors:\n {competitor_lines}"
61 )
64def _fmt_scoreboard(matches: list[Match]) -> str:
65 """Format a list of matches for the scoreboard tool."""
66 if not matches:
67 return "No matches found for the requested date."
68 return "\n\n".join(_fmt_match(m) for m in matches)
71def _fmt_team_schedule(matches: list[Match]) -> str:
72 """Format a list of matches for the team-schedule tool."""
73 if not matches:
74 return "No scheduled matches found for this team."
75 return "\n\n".join(_fmt_match(m) for m in matches)
78def _fmt_player(i: int, player: Player) -> str:
79 """Format a single roster row."""
80 jersey = f"#{player.jersey}" if player.jersey else " "
81 pos = f" ({player.position_abbr})" if player.position_abbr else ""
82 extras = []
83 if player.position:
84 extras.append(player.position)
85 if player.citizenship:
86 extras.append(player.citizenship)
87 if player.age is not None:
88 extras.append(f"age {player.age}")
89 suffix = f" — {', '.join(extras)}" if extras else ""
90 return f"{i}. {jersey} {player.full_name}{pos}{suffix}"
93def _fmt_roster(players: list[Player]) -> str:
94 """Format a roster as a numbered list."""
95 if not players:
96 return "No players found for this team."
97 return "\n".join(_fmt_player(i, p) for i, p in enumerate(players, 1))
100def _fmt_event(event: MatchEvent) -> str:
101 """Format a single key event as a one-line summary."""
102 marker = "⚽" if event.scoring else "•"
103 parts = [f" {marker} {event.clock}"]
104 if event.team_name:
105 parts.append(f"({event.team_name})")
106 parts.append(event.text or event.type)
107 return " ".join(parts)
110def _fmt_venue_line(details: MatchDetails) -> str | None:
111 """Build the venue line, or return None if no venue is set."""
112 if not details.venue:
113 return None
114 if details.venue_city:
115 return f" Venue: {details.venue} ({details.venue_city})"
116 return f" Venue: {details.venue}"
119def _fmt_match_details(details: MatchDetails) -> str:
120 """Format a MatchDetails as a readable multi-line summary."""
121 score = f"{details.home_score or '?'} - {details.away_score or '?'}"
122 lines = [
123 f"{details.home_team} {score} {details.away_team}",
124 f" Date: {details.date}",
125 f" Status: {details.status_detail}",
126 ]
127 venue_line = _fmt_venue_line(details)
128 if venue_line:
129 lines.append(venue_line)
130 if details.attendance is not None:
131 lines.append(f" Attendance: {details.attendance:,}")
132 if details.key_events:
133 lines.append(" Key events:")
134 lines.extend(_fmt_event(e) for e in details.key_events)
135 return "\n".join(lines)
138def _fmt_article(i: int, article: NewsArticle) -> str:
139 """Format a single news article as a multi-line entry."""
140 lines = [f"{i}. {article.headline}"]
141 if article.published:
142 lines.append(f" Published: {article.published}")
143 if article.description:
144 lines.append(f" {article.description}")
145 if article.link:
146 lines.append(f" {article.link}")
147 return "\n".join(lines)
150def _fmt_news(articles: list[NewsArticle]) -> str:
151 """Format a list of news articles."""
152 if not articles:
153 return "No news articles available."
154 return "\n\n".join(_fmt_article(i, a) for i, a in enumerate(articles, 1))
157def _fmt_standing(i: int, standing: Standing) -> str:
158 """Format a single standings row."""
159 return (
160 f"{i}. {standing.team.display_name} ({standing.team.abbreviation})"
161 f" — {standing.points} pts"
162 f" | W:{standing.wins} L:{standing.losses} T:{standing.ties}"
163 f" | GF:{standing.goals_for} GA:{standing.goals_against} GD:{standing.goal_difference:+d}"
164 )
167def _fmt_standings(standings: list[Standing]) -> str:
168 """Format the USL Super League standings as a single numbered list."""
169 if not standings:
170 return "No standings data available."
171 return "\n".join(_fmt_standing(i, s) for i, s in enumerate(standings, 1))
174def _fmt_opponent_row(opp: OpponentPPG, meetings: int) -> str:
175 """Format one opponent row, marking repeat fixtures as 'x2', 'x3', etc."""
176 suffix = f" x{meetings}" if meetings > 1 else ""
177 return f" - {opp.team.display_name}{suffix}: {opp.points_per_game:.2f} ({opp.points} pts in {opp.matches_played} GP)"
180def _fmt_strength_of_schedule(sos: StrengthOfSchedule) -> str:
181 """Format a StrengthOfSchedule as a labeled summary plus opponent breakdown.
183 Repeat fixtures (an opponent met twice via home + away) collapse into a
184 single row marked ``x2`` — the average PPG calculation already weights
185 them correctly, so the output stays tidy without losing information.
186 """
187 if sos.matches_played == 0:
188 return f"{sos.team.display_name}: no matches played yet — strength of schedule unavailable."
189 counts: dict[str, int] = {}
190 unique: list[OpponentPPG] = []
191 for opp in sos.opponents:
192 if opp.team.id in counts:
193 counts[opp.team.id] += 1
194 else:
195 counts[opp.team.id] = 1
196 unique.append(opp)
197 lines = [
198 f"{sos.team.display_name} — Strength of Schedule",
199 f" Matches played: {sos.matches_played}",
200 f" Average opponent PPG: {sos.average_opponent_ppg:.2f}",
201 " Opponents faced (current PPG):",
202 ]
203 lines.extend(_fmt_opponent_row(opp, counts[opp.team.id]) for opp in unique)
204 return "\n".join(lines)
207def _fmt_tier_record(t: TierRecord) -> str:
208 """Format a single TierRecord row as 'Label (ranks N-M): W-L-T'."""
209 return f" {t.label} (ranks {t.rank_low}-{t.rank_high}): {t.wins}-{t.losses}-{t.ties}"
212def _fmt_results_by_tier(rbt: ResultsByOpponentTier) -> str:
213 """Format a ResultsByOpponentTier as a labeled W-L-T breakdown by tier."""
214 lines = [f"{rbt.team.display_name} — Results by Opponent Tier (tier size: {rbt.tier_size})"]
215 lines.extend(_fmt_tier_record(t) for t in rbt.tiers)
216 return "\n".join(lines)
219def _fmt_adjusted_ppg(a: AdjustedPointsPerGame) -> str:
220 """Format an AdjustedPointsPerGame as a labeled summary."""
221 return (
222 f"{a.team.display_name} — Adjusted Points Per Game\n"
223 f" Record: {a.points} pts in {a.matches_played} GP\n"
224 f" Raw PPG: {a.raw_ppg:.2f}\n"
225 f" Average opponent PPG: {a.average_opponent_ppg:.2f}\n"
226 f" League average PPG: {a.league_average_ppg:.2f}\n"
227 f" Adjusted PPG: {a.adjusted_ppg:.2f}"
228 )