Coverage for dao/standings.py: 50.30%
119 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-15 12:31 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-15 12:31 +0000
1"""
2Pure functions for league standings calculation.
4This module contains pure functions with no external dependencies,
5making them trivially testable without mocking.
7These functions are used by MatchDAO.get_league_table() but can be
8unit tested independently.
9"""
11from collections import defaultdict
12from datetime import date
13from operator import itemgetter
16def filter_completed_matches(matches: list[dict]) -> list[dict]:
17 """
18 Filter matches to only include completed ones.
20 Uses match_status field if available, otherwise falls back to
21 date-based logic for backwards compatibility.
23 Args:
24 matches: List of match dictionaries from database
26 Returns:
27 List of completed matches only
28 """
29 played_matches = []
30 for match in matches:
31 match_status = match.get("match_status")
32 if match_status: 32 ↛ 38line 32 didn't jump to line 38 because the condition on line 32 was always true
33 # Use match_status field if available
34 if match_status in ("completed", "forfeit"):
35 played_matches.append(match)
36 else:
37 # Fallback to date-based logic for backwards compatibility
38 match_date_str = match.get("match_date")
39 if match_date_str:
40 match_date = date.fromisoformat(match_date_str)
41 if match_date <= date.today():
42 played_matches.append(match)
43 return played_matches
46def filter_same_division_matches(matches: list[dict], division_id: int) -> list[dict]:
47 """
48 Filter matches to only include those where both teams are in the same division.
50 Args:
51 matches: List of match dictionaries
52 division_id: Division ID to filter by
54 Returns:
55 List of matches where both teams are in the specified division
56 """
57 same_division_matches = []
58 for match in matches:
59 home_div_id = match.get("home_team", {}).get("division_id")
60 away_div_id = match.get("away_team", {}).get("division_id")
62 # Only include if both teams are in the requested division
63 if home_div_id == division_id and away_div_id == division_id:
64 same_division_matches.append(match)
66 return same_division_matches
69def filter_by_match_type(matches: list[dict], match_type: str) -> list[dict]:
70 """
71 Filter matches by match type name.
73 Args:
74 matches: List of match dictionaries
75 match_type: Match type name to filter by (e.g., "League")
77 Returns:
78 List of matches with the specified match type
79 """
80 return [m for m in matches if m.get("match_type", {}).get("name") == match_type]
83def calculate_standings(matches: list[dict]) -> list[dict]:
84 """
85 Calculate league standings from a list of completed matches.
87 This is a pure function with no external dependencies.
88 It expects matches that have already been filtered to only include
89 completed matches with scores.
91 Args:
92 matches: List of match dictionaries, each containing:
93 - home_team: dict with "name" key
94 - away_team: dict with "name" key
95 - home_score: int or None
96 - away_score: int or None
98 Returns:
99 List of team standings sorted by:
100 1. Points (descending)
101 2. Goal difference (descending)
102 3. Goals scored (descending)
104 Each standing contains:
105 - team: Team name
106 - played: Matches played
107 - wins: Number of wins
108 - draws: Number of draws
109 - losses: Number of losses
110 - goals_for: Goals scored
111 - goals_against: Goals conceded
112 - goal_difference: goals_for - goals_against
113 - points: Total points (3 for win, 1 for draw)
115 Business Rules:
116 - Win = 3 points
117 - Draw = 1 point for each team
118 - Loss = 0 points
119 - Matches without scores are skipped
120 """
121 standings = defaultdict(
122 lambda: {
123 "played": 0,
124 "wins": 0,
125 "draws": 0,
126 "losses": 0,
127 "goals_for": 0,
128 "goals_against": 0,
129 "goal_difference": 0,
130 "points": 0,
131 "logo_url": None,
132 }
133 )
135 for match in matches:
136 home_team = match["home_team"]["name"]
137 away_team = match["away_team"]["name"]
138 home_score = match.get("home_score")
139 away_score = match.get("away_score")
141 # Skip matches without scores
142 if home_score is None or away_score is None:
143 continue
145 # Capture club logo_url from joined club data
146 home_club = match["home_team"].get("club") or {}
147 away_club = match["away_team"].get("club") or {}
148 if not standings[home_team]["logo_url"]: 148 ↛ 150line 148 didn't jump to line 150 because the condition on line 148 was always true
149 standings[home_team]["logo_url"] = home_club.get("logo_url")
150 if not standings[away_team]["logo_url"]: 150 ↛ 154line 150 didn't jump to line 154 because the condition on line 150 was always true
151 standings[away_team]["logo_url"] = away_club.get("logo_url")
153 # Update played count
154 standings[home_team]["played"] += 1
155 standings[away_team]["played"] += 1
157 # Update goals
158 standings[home_team]["goals_for"] += home_score
159 standings[home_team]["goals_against"] += away_score
160 standings[away_team]["goals_for"] += away_score
161 standings[away_team]["goals_against"] += home_score
163 # Update wins/draws/losses and points
164 if home_score > away_score:
165 # Home win
166 standings[home_team]["wins"] += 1
167 standings[home_team]["points"] += 3
168 standings[away_team]["losses"] += 1
169 elif away_score > home_score:
170 # Away win
171 standings[away_team]["wins"] += 1
172 standings[away_team]["points"] += 3
173 standings[home_team]["losses"] += 1
174 else:
175 # Draw
176 standings[home_team]["draws"] += 1
177 standings[away_team]["draws"] += 1
178 standings[home_team]["points"] += 1
179 standings[away_team]["points"] += 1
181 # Convert to list and calculate goal difference
182 table = []
183 for team, stats in standings.items():
184 stats["goal_difference"] = stats["goals_for"] - stats["goals_against"]
185 stats["team"] = team
186 table.append(stats)
188 # Sort by points, goal difference, goals scored (all descending)
189 table.sort(
190 key=lambda x: (x["points"], x["goal_difference"], x["goals_for"]),
191 reverse=True,
192 )
194 return table
197def get_team_form(matches: list[dict], last_n: int = 5) -> dict[str, list[str]]:
198 """
199 Calculate recent form (W/D/L) for each team from completed matches.
201 Args:
202 matches: List of completed match dicts with scores and match_date
203 last_n: Number of recent results to return (default 5)
205 Returns:
206 Dict mapping team name to list of results, most recent last.
207 e.g. {"Northeast IFA": ["W", "L", "D", "W", "W"]}
208 """
209 # Build per-team results ordered by match_date
210 team_results: dict[str, list[tuple[str, str]]] = defaultdict(list)
212 for match in matches:
213 home_team = match["home_team"]["name"]
214 away_team = match["away_team"]["name"]
215 home_score = match.get("home_score")
216 away_score = match.get("away_score")
217 match_date = match.get("match_date", "")
219 if home_score is None or away_score is None:
220 continue
222 if home_score > away_score:
223 team_results[home_team].append((match_date, "W"))
224 team_results[away_team].append((match_date, "L"))
225 elif away_score > home_score:
226 team_results[home_team].append((match_date, "L"))
227 team_results[away_team].append((match_date, "W"))
228 else:
229 team_results[home_team].append((match_date, "D"))
230 team_results[away_team].append((match_date, "D"))
232 # Sort by date and take last N
233 form: dict[str, list[str]] = {}
234 for team, results in team_results.items():
235 results.sort(key=itemgetter(0))
236 form[team] = [r for _, r in results[-last_n:]]
238 return form
241def calculate_position_movement(
242 current_matches: list[dict],
243) -> dict[str, int]:
244 """
245 Calculate position change for each team by comparing standings
246 with and without the most recent match day's results.
248 Args:
249 current_matches: All completed matches (with scores and match_date)
251 Returns:
252 Dict mapping team name to position change (positive = moved up,
253 negative = moved down, 0 = unchanged). Teams with no previous
254 position (first match day) get 0.
255 """
256 # Find distinct match dates
257 match_dates = set()
258 for match in current_matches:
259 md = match.get("match_date")
260 if md and match.get("home_score") is not None:
261 match_dates.add(md)
263 if len(match_dates) < 2:
264 # Only one match day — no previous standings to compare
265 return {}
267 latest_date = max(match_dates)
269 # Previous standings: exclude the latest match day
270 previous_matches = [m for m in current_matches if m.get("match_date") != latest_date]
271 previous_table = calculate_standings(previous_matches)
273 # Build position maps (1-indexed)
274 previous_positions = {
275 row["team"]: i + 1 for i, row in enumerate(previous_table)
276 }
278 current_table = calculate_standings(current_matches)
279 current_positions = {
280 row["team"]: i + 1 for i, row in enumerate(current_table)
281 }
283 # Calculate movement: previous - current (positive = moved up)
284 movement: dict[str, int] = {}
285 for team, current_pos in current_positions.items():
286 previous_pos = previous_positions.get(team)
287 if previous_pos is None:
288 # New team (first match day for them)
289 movement[team] = 0
290 else:
291 movement[team] = previous_pos - current_pos
293 return movement
296def calculate_standings_with_extras(matches: list[dict]) -> list[dict]:
297 """
298 Calculate standings enriched with position movement and recent form.
300 Wraps calculate_standings() and adds:
301 - form: list of last 5 results (e.g. ["W", "D", "L", "W", "W"])
302 - position_change: int (positive = moved up, negative = down, 0 = same)
304 Args:
305 matches: List of completed match dicts
307 Returns:
308 Same as calculate_standings() but each row also has 'form' and
309 'position_change' keys.
310 """
311 table = calculate_standings(matches)
312 form = get_team_form(matches)
313 movement = calculate_position_movement(matches)
315 for row in table:
316 team = row["team"]
317 row["form"] = form.get(team, [])
318 row["position_change"] = movement.get(team, 0)
320 return table