Coverage for dao/standings.py: 50.30%

119 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-04-15 12:24 +0000

1""" 

2Pure functions for league standings calculation. 

3 

4This module contains pure functions with no external dependencies, 

5making them trivially testable without mocking. 

6 

7These functions are used by MatchDAO.get_league_table() but can be 

8unit tested independently. 

9""" 

10 

11from collections import defaultdict 

12from datetime import date 

13from operator import itemgetter 

14 

15 

16def filter_completed_matches(matches: list[dict]) -> list[dict]: 

17 """ 

18 Filter matches to only include completed ones. 

19 

20 Uses match_status field if available, otherwise falls back to 

21 date-based logic for backwards compatibility. 

22 

23 Args: 

24 matches: List of match dictionaries from database 

25 

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 

44 

45 

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. 

49 

50 Args: 

51 matches: List of match dictionaries 

52 division_id: Division ID to filter by 

53 

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") 

61 

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) 

65 

66 return same_division_matches 

67 

68 

69def filter_by_match_type(matches: list[dict], match_type: str) -> list[dict]: 

70 """ 

71 Filter matches by match type name. 

72 

73 Args: 

74 matches: List of match dictionaries 

75 match_type: Match type name to filter by (e.g., "League") 

76 

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] 

81 

82 

83def calculate_standings(matches: list[dict]) -> list[dict]: 

84 """ 

85 Calculate league standings from a list of completed matches. 

86 

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. 

90 

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 

97 

98 Returns: 

99 List of team standings sorted by: 

100 1. Points (descending) 

101 2. Goal difference (descending) 

102 3. Goals scored (descending) 

103 

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) 

114 

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 ) 

134 

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") 

140 

141 # Skip matches without scores 

142 if home_score is None or away_score is None: 

143 continue 

144 

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") 

152 

153 # Update played count 

154 standings[home_team]["played"] += 1 

155 standings[away_team]["played"] += 1 

156 

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 

162 

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 

180 

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) 

187 

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 ) 

193 

194 return table 

195 

196 

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. 

200 

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) 

204 

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) 

211 

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", "") 

218 

219 if home_score is None or away_score is None: 

220 continue 

221 

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")) 

231 

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:]] 

237 

238 return form 

239 

240 

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. 

247 

248 Args: 

249 current_matches: All completed matches (with scores and match_date) 

250 

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) 

262 

263 if len(match_dates) < 2: 

264 # Only one match day — no previous standings to compare 

265 return {} 

266 

267 latest_date = max(match_dates) 

268 

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) 

272 

273 # Build position maps (1-indexed) 

274 previous_positions = { 

275 row["team"]: i + 1 for i, row in enumerate(previous_table) 

276 } 

277 

278 current_table = calculate_standings(current_matches) 

279 current_positions = { 

280 row["team"]: i + 1 for i, row in enumerate(current_table) 

281 } 

282 

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 

292 

293 return movement 

294 

295 

296def calculate_standings_with_extras(matches: list[dict]) -> list[dict]: 

297 """ 

298 Calculate standings enriched with position movement and recent form. 

299 

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) 

303 

304 Args: 

305 matches: List of completed match dicts 

306 

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) 

314 

315 for row in table: 

316 team = row["team"] 

317 row["form"] = form.get(team, []) 

318 row["position_change"] = movement.get(team, 0) 

319 

320 return table