From 15fa91ab16afb0d55134636f1399a50bac0087c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 01:53:46 +0000 Subject: [PATCH] feat: add a team runs per game page with MLB context Adds /runs, a third metric page answering how many runs per game a team is scoring, how that is changing over the season, and how its season average compares with MLB overall. Team Runs/Game is total runs scored divided by the stored completed team-game records for that team-season. One value backs the Season Avg card, the dashed reference line, and the MLB comparison, so the page cannot disagree with itself. MLB Runs/Game is total runs across all persisted team-game records for the season divided by the total number of those records. It is game-weighted rather than the mean of team averages: clubs play unequal numbers of games, and weighting them equally answers a different question. Nothing assumes 30 teams, 162 games, or any fixed record count. The MLB average is gated on the existing COMPLETE league-coverage rule, reused rather than re-implemented so the three metric pages cannot disagree about a season. INCOMPLETE, RUNNING, and no coverage record all withhold the line and show a dash, never 0.00, and the team's own chart renders in every one of those states. Runs have been persisted as a required non-negative column since the first migration, so this needed no new MLB request, no new column, and no migration. Unlike batting strikeouts there is no unknown-total case, so no nullable-data machinery was added. Hits, batting strikeouts, and runs remain three explicit analytics modules rather than a shared metric framework. Only the coverage rule and the chart rendering helpers are shared, because sharing those prevents a real inconsistency. Navigation gains a third entry; its tests now assert the full list, so a page added without a route fails there. --- README.md | 70 +++- app/analytics/__init__.py | 6 + app/analytics/league_runs.py | 138 +++++++ app/analytics/team_runs.py | 140 +++++++ app/schemas/analytics.py | 200 ++++++++- app/web/charts.py | 179 +++++++- app/web/formatting.py | 84 ++++ app/web/navigation.py | 17 +- app/web/routes.py | 152 ++++++- app/web/templates/runs.html | 108 +++++ docs/team-runs-visualization.md | 359 ++++++++++++++++ tests/factories.py | 36 +- tests/test_analytics_league_runs.py | 306 ++++++++++++++ tests/test_analytics_schemas.py | 194 +++++++++ tests/test_analytics_team_runs.py | 237 +++++++++++ tests/test_charts_runs.py | 230 +++++++++++ tests/test_formatting.py | 107 +++++ tests/test_navigation.py | 30 +- tests/test_web_runs.py | 491 ++++++++++++++++++++++ tests/test_web_runs_league_comparison.py | 504 +++++++++++++++++++++++ 20 files changed, 3562 insertions(+), 26 deletions(-) create mode 100644 app/analytics/league_runs.py create mode 100644 app/analytics/team_runs.py create mode 100644 app/web/templates/runs.html create mode 100644 docs/team-runs-visualization.md create mode 100644 tests/test_analytics_league_runs.py create mode 100644 tests/test_analytics_team_runs.py create mode 100644 tests/test_charts_runs.py create mode 100644 tests/test_web_runs.py create mode 100644 tests/test_web_runs_league_comparison.py diff --git a/README.md b/README.md index eb77df8..c6bcb30 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ shown only when Milestone 4 recorded `COMPLETE` league coverage for the selected season. See [docs/team-vs-mlb-comparison.md](docs/team-vs-mlb-comparison.md). +Issue #24 adds a third metric page: a team's runs scored per game, with the same +rolling average, season average, and MLB comparison the other pages carry. Runs +have been persisted as a required column since Milestone 2, so no new MLB +request, column, or migration was needed. See +[docs/team-runs-visualization.md](docs/team-runs-visualization.md). + ## Planned MVP A local web application that: @@ -135,6 +141,7 @@ Then open [http://127.0.0.1:8000](http://127.0.0.1:8000). - `/` — team hitting trends (hits per game) - `/strikeouts` — team batting strikeout trends +- `/runs` — team run scoring trends (runs scored per game) - `/health` — JSON health check ## Team hitting trends page @@ -228,6 +235,53 @@ unchanged MLB data counts them as **unchanged**. The hits page keeps working normally throughout, before and after the backfill. +## Team run scoring trends page + +`/runs` charts one team's **runs scored** per game for one season, with a +trailing rolling average, the team's season average, and — when league coverage +allows — an MLB average. + +```text +http://127.0.0.1:8000/runs?team_id=136&season=2025&window=15 +``` + +It takes the same `team_id`, `season`, and `window` parameters as the other +metric pages, with the same defaults, and the navigation carries the current +selection between all three. `/` and `/strikeouts` are unchanged. + +**Runs scored, not runs allowed.** Every label says so. Nothing on this page is +a run differential, and the runs a team gave up are not stored or shown. + +**Team Runs/Game** is total runs scored divided by the number of stored +completed team-game records for that team-season. One value: the Season Avg +card, the dashed reference line, and the MLB comparison all read it. + +**MLB Runs/Game** is total runs across all persisted team-game records for the +season divided by the total number of those records — game-weighted, so a club +that has played more games counts for more. One real MLB game contributes two +team-game records, one per club, so both sides of the comparison are per-team +per-game numbers. Nothing assumes 30 teams, 162 games, or any fixed record +count. + +**The MLB average is gated on coverage.** It is shown only when a league-season +import recorded `COMPLETE` coverage for that season. `INCOMPLETE`, `RUNNING`, +and a season no league import has touched all show no MLB line and a `vs MLB` +card reading `—`, never `0.00`. The team's own chart renders in every one of +those states. `COMPLETE` describes the refresh, not the season, so an +in-progress season with complete coverage does show a comparison, calculated +from the games currently stored. + +**`vs MLB` is descriptive subtraction.** Positive means the team scored more +runs per game than MLB overall; negative, fewer. It is not a rank, a +percentile, a significance test, or a park- or opponent-adjusted figure. See +[docs/team-runs-visualization.md](docs/team-runs-visualization.md) for the +formulas and the limitations. + +**No re-import is needed.** Unlike batting strikeouts, `runs` has been a +required non-negative column since the first migration, so every stored +team-season already has real run totals. This page added no column and no +migration. + ## Team game-level hitting data Milestone 1 retrieves one normalized batting line per completed regular-season @@ -441,7 +495,9 @@ poetry run ruff format --check . │ └── templates/ │ ├── base.html │ ├── error.html -│ └── index.html +│ ├── index.html +│ ├── runs.html +│ └── strikeouts.html ├── scripts/ │ ├── import_league_season.py │ ├── inspect_game_logs.py @@ -450,7 +506,9 @@ poetry run ruff format --check . │ ├── league-season-ingestion.md │ ├── team-game-data-spike.md │ ├── team-hits-visualization.md +│ ├── team-runs-visualization.md │ ├── team-season-ingestion.md +│ ├── team-strikeouts-visualization.md │ └── team-vs-mlb-comparison.md ├── tests/ │ ├── conftest.py @@ -494,10 +552,14 @@ Milestone 5 delivered the MLB hits-per-game comparison the Milestone 4 coverage state unblocked, gated on that state exactly as planned. See [docs/team-vs-mlb-comparison.md](docs/team-vs-mlb-comparison.md). +Issue #23 extended the same comparison to batting strikeouts, and issue #24 +added the runs page with it. All three read one shared coverage rule, so they +cannot disagree about whether a season may be described as MLB-wide. + Still unimplemented, and each needing its own definition before it is drawn: -league rank, percentiles, normalized indexes, and a league comparison for -batting strikeouts. Any of them must check a season's stored ingestion coverage -before presenting a league statistic, the way the hits comparison does. +league rank, percentiles, and normalized indexes. Any of them must check a +season's stored ingestion coverage before presenting a league statistic, the +way the existing comparisons do. ## Disclaimer diff --git a/app/analytics/__init__.py b/app/analytics/__init__.py index f3fd560..0be1444 100644 --- a/app/analytics/__init__.py +++ b/app/analytics/__init__.py @@ -5,6 +5,10 @@ TeamHitsAnalysisError, build_team_hits_analysis, ) +from app.analytics.team_runs import ( + TeamRunsAnalysisError, + build_team_runs_analysis, +) from app.analytics.team_strikeouts import ( MissingStrikeoutDataError, TeamStrikeoutsAnalysisError, @@ -15,7 +19,9 @@ "DEFAULT_ROLLING_WINDOW", "MissingStrikeoutDataError", "TeamHitsAnalysisError", + "TeamRunsAnalysisError", "TeamStrikeoutsAnalysisError", "build_team_hits_analysis", + "build_team_runs_analysis", "build_team_strikeouts_analysis", ] diff --git a/app/analytics/league_runs.py b/app/analytics/league_runs.py new file mode 100644 index 0000000..0b7d222 --- /dev/null +++ b/app/analytics/league_runs.py @@ -0,0 +1,138 @@ +"""MLB-wide run-scoring calculations over normalized game batting lines. + +Answers one question: + + How many runs per game does this team score compared with MLB overall? + +Separate from ``app/analytics/team_runs.py`` because it describes MLB rather +than one club, and separate from ``app/analytics/league_hitting.py`` and +``app/analytics/league_strikeouts.py`` because runs are a third statistic with +their own labels and their own data history. Three explicit modules are easier +to read and change than one league-metric framework covering all of them. Like +every other module under ``app/analytics``, this one knows nothing about +FastAPI, Jinja, SQLAlchemy, Plotly, or the MLB API. + +Runs scored throughout: runs the counted clubs put on the board. Because every +real game contributes one record per club, the same runs appear once as the +scoring team's total and never as the opponent's, so no run differential is +implied anywhere here. +""" + +from collections.abc import Sequence + +from app.analytics.league_hitting import supports_league_wide_average +from app.schemas.analytics import ( + LeagueRunsContext, + TeamRunsAnalysis, + TeamRunsLeagueComparison, +) +from app.schemas.games import TeamGameBattingLine +from app.schemas.ingestion import LeagueSeasonIngestionState + + +class LeagueRunsAnalysisError(ValueError): + """League run analysis was requested with input it cannot describe.""" + + +def supports_league_wide_runs_average( + coverage: LeagueSeasonIngestionState | None, +) -> bool: + """Say whether a season's coverage permits an MLB-wide runs average. + + This is the Milestone 5 coverage rule, unchanged and deliberately not + re-implemented here: ``COMPLETE`` coverage from the latest league-wide + refresh, never a row count, a team count, or a game count. Three copies of + that rule could drift and let one page call a season MLB-wide while another + did not. + + ``COMPLETE`` describes the refresh, not the season. An in-progress season + whose latest league-wide run covered every discovered team qualifies, and + what the resulting average describes is the completed games currently + stored. + + Unlike batting strikeouts, complete coverage is both necessary **and** + sufficient here: ``runs`` is required on every persisted team-game record, + so a covered season cannot be holding unknown run totals. + """ + return supports_league_wide_average(coverage) + + +def build_league_runs_context( + games: Sequence[TeamGameBattingLine], +) -> LeagueRunsContext: + """Calculate MLB runs per game across every stored team-game record. + + The average is **game-weighted**:: + + MLB Runs/Game = total runs / total team-game records + + Every stored team-game record counts once, so a club that has played more + games contributes proportionally more. Averaging each club's own average + instead would silently give a team with 40 games the same weight as a team + with 162, which answers a different question and is wrong for this one. + + The denominator counts team-game records, not MLB games: one real game + produces two records once both clubs are stored. That makes this a per-team + per-game number, which is what the team side of the comparison is too. + + Raises + ------ + LeagueRunsAnalysisError + ``games`` is empty, or the records span more than one season. + """ + if not games: + raise LeagueRunsAnalysisError( + "Cannot describe MLB run scoring from no team-game records" + ) + + seasons = {game.season for game in games} + if len(seasons) > 1: + raise LeagueRunsAnalysisError( + f"All team-game records must belong to one season, got {sorted(seasons)}" + ) + + team_game_records = len(games) + total_runs = sum(game.runs for game in games) + return LeagueRunsContext( + season=games[0].season, + teams_represented=len({game.team_id for game in games}), + team_game_records=team_game_records, + total_runs=total_runs, + runs_per_game=total_runs / team_game_records, + ) + + +def compare_team_runs_to_league( + analysis: TeamRunsAnalysis, + league: LeagueRunsContext, +) -> TeamRunsLeagueComparison: + """Place a team-season's runs per game beside MLB overall. + + The team side is ``TeamRunsAnalysis.summary.season_average``, which is the + same number the chart's team reference line and the Season Avg card read, + so the page cannot show two different team averages. + + The difference is descriptive subtraction and nothing more. It is not + normalized, not ranked, not tested for significance, not adjusted for park + or opponent, and it says nothing about the runs the team allowed. + + Raises + ------ + LeagueRunsAnalysisError + The team analysis and the league context describe different seasons. + """ + if analysis.season != league.season: + raise LeagueRunsAnalysisError( + f"Cannot compare a {analysis.season} team-season against " + f"{league.season} MLB context" + ) + + team_runs_per_game = analysis.summary.season_average + return TeamRunsLeagueComparison( + team_id=analysis.team_id, + team_name=analysis.team_name, + season=analysis.season, + team_runs_per_game=team_runs_per_game, + league=league, + difference_vs_mlb=team_runs_per_game - league.runs_per_game, + ) diff --git a/app/analytics/team_runs.py b/app/analytics/team_runs.py new file mode 100644 index 0000000..2e736f0 --- /dev/null +++ b/app/analytics/team_runs.py @@ -0,0 +1,140 @@ +"""Team run-scoring calculations over normalized game batting lines. + +Answers one question: + + How many runs per game is this team scoring, and how is that changing as + the season progresses? + +Like ``team_hitting`` and ``team_strikeouts``, this layer is free of FastAPI, +Jinja, SQLAlchemy, Plotly, and the MLB API. It takes ``TeamGameBattingLine`` +domain records and returns a ``TeamRunsAnalysis``. + +The shape mirrors the hits and batting strikeout analyses deliberately rather +than sharing a metric abstraction with them. Three readable implementations are +easier to change than one parameterized one, and the three statistics are not +interchangeable: hits, batting strikeouts, and runs answer different questions. + +Runs scored throughout: runs the selected team put on the board. Runs allowed +and run differential are different statistics and are not calculated here. +""" + +from collections.abc import Sequence + +from app.schemas.analytics import TeamRunsAnalysis, TeamRunsPoint, TeamRunsSummary +from app.schemas.games import TeamGameBattingLine + +DEFAULT_ROLLING_WINDOW = 15 + + +class TeamRunsAnalysisError(ValueError): + """Team run analysis was requested with input it cannot describe.""" + + +def build_team_runs_analysis( + games: Sequence[TeamGameBattingLine], + *, + rolling_window: int = DEFAULT_ROLLING_WINDOW, +) -> TeamRunsAnalysis: + """Calculate a team-season's runs-per-game trend. + + Games are ordered by date, then MLB game number, then game id, so both + halves of a doubleheader keep their real sequence. The x axis of the chart + is ``season_game_number``, a continuous 1-based index over that order. + + There is no unknown-value case to guard against. ``runs`` is required on + every persisted team-game record, so no equivalent of the batting strikeout + backfill state exists for this metric. + + Raises + ------ + TeamRunsAnalysisError + ``games`` is empty, mixes team-seasons, or ``rolling_window`` is not a + positive number of games. + """ + if rolling_window < 1: + raise TeamRunsAnalysisError( + f"rolling_window must be at least 1 game, got {rolling_window}" + ) + if not games: + raise TeamRunsAnalysisError( + "Cannot analyse run scoring for a team-season with no completed games" + ) + + ordered = sorted( + games, key=lambda game: (game.game_date, game.game_number, game.game_pk) + ) + team_ids = {game.team_id for game in ordered} + seasons = {game.season for game in ordered} + if len(team_ids) > 1 or len(seasons) > 1: + raise TeamRunsAnalysisError( + "All games must belong to one team and one season, got teams " + f"{sorted(team_ids)} and seasons {sorted(seasons)}" + ) + + runs = [game.runs for game in ordered] + rolling_averages = _trailing_averages(runs, rolling_window) + points = tuple( + TeamRunsPoint( + game_pk=game.game_pk, + game_number=game.game_number, + season_game_number=index + 1, + game_date=game.game_date, + opponent_name=game.opponent_name, + home_away=game.home_away, + runs=game.runs, + rolling_average=rolling_average, + ) + for index, (game, rolling_average) in enumerate( + zip(ordered, rolling_averages, strict=True) + ) + ) + + return TeamRunsAnalysis( + team_id=ordered[-1].team_id, + team_name=ordered[-1].team_name, + season=ordered[-1].season, + rolling_window=rolling_window, + points=points, + summary=_build_summary(runs, rolling_window=rolling_window), + ) + + +def _trailing_averages(values: list[int], window: int) -> list[float]: + """Return the trailing mean ending at each position. + + The average at index ``i`` covers the ``window`` most recent values up to + and including ``i``. Early positions use every value available so far + rather than producing a gap, so game 1 of a season is its own average. + """ + averages: list[float] = [] + running = 0 + for index, value in enumerate(values): + running += value + if index >= window: + running -= values[index - window] + averages.append(running / min(index + 1, window)) + return averages + + +def _build_summary(runs: list[int], *, rolling_window: int) -> TeamRunsSummary: + games_played = len(runs) + + recent = runs[-min(rolling_window, games_played) :] + recent_average = sum(recent) / len(recent) + + prior_window_average: float | None = None + change_vs_prior_window: float | None = None + # Two complete windows are required; comparing partial windows would report + # a change caused by sample size rather than by run scoring. + if games_played >= 2 * rolling_window: + prior = runs[games_played - 2 * rolling_window : games_played - rolling_window] + prior_window_average = sum(prior) / len(prior) + change_vs_prior_window = recent_average - prior_window_average + + return TeamRunsSummary( + games_played=games_played, + season_average=sum(runs) / games_played, + recent_average=recent_average, + prior_window_average=prior_window_average, + change_vs_prior_window=change_vs_prior_window, + ) diff --git a/app/schemas/analytics.py b/app/schemas/analytics.py index 639fd07..bb4eb97 100644 --- a/app/schemas/analytics.py +++ b/app/schemas/analytics.py @@ -4,9 +4,9 @@ presents it. They carry finished numbers, not raw MLB payloads, and they keep dates as ``date`` objects so presentation can choose its own formatting. -Hits and batting strikeouts are modelled separately rather than through a -shared metric type. They are read the same way but mean different things, and -one honest duplication is cheaper to follow than an abstraction covering two +Hits, batting strikeouts, and runs are modelled separately rather than through +a shared metric type. They are read the same way but mean different things, and +honest duplication is cheaper to follow than an abstraction covering three cases. """ @@ -410,3 +410,197 @@ def _comparison_is_internally_consistent(self) -> TeamStrikeoutsLeagueComparison f"({expected})" ) return self + + +class TeamRunsPoint(BaseModel): + """One completed game plotted on the team runs chart.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + game_pk: int = Field(gt=0, description="MLB game identifier.") + game_number: int = Field( + ge=1, + description="MLB game number on the date, 2 for the second game of a " + "doubleheader. Used for ordering, not for the x axis.", + ) + season_game_number: int = Field( + ge=1, + description="Continuous 1-based position of the game within the season.", + ) + game_date: date = Field(description="Official date the game counts against.") + opponent_name: str = Field( + min_length=1, description="Display name of the opponent." + ) + home_away: HomeAway = Field(description="Whether the team was home or away.") + runs: int = Field( + ge=0, + description="Runs scored by the team in this game. Runs scored, not runs " + "allowed, and never a run differential.", + ) + rolling_average: float = Field( + ge=0, + description="Trailing rolling runs-per-game average ending at this game.", + ) + + +class TeamRunsSummary(BaseModel): + """Headline numbers describing a team-season's run scoring. + + ``season_average`` is the single authoritative season average; the chart's + reference line, the summary card, and the MLB comparison all read it from + here, so the page cannot show two different team averages. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + games_played: int = Field(ge=1, description="Completed games analysed.") + season_average: float = Field( + ge=0, description="Runs per game across the stored completed games." + ) + recent_average: float = Field( + ge=0, + description="Runs per game over the most recent rolling window.", + ) + prior_window_average: float | None = Field( + default=None, + ge=0, + description="Runs per game over the window immediately before the recent " + "one, or None when two complete windows do not exist.", + ) + change_vs_prior_window: float | None = Field( + default=None, + description="recent_average - prior_window_average, or None.", + ) + + @model_validator(mode="after") + def _prior_window_fields_agree(self) -> TeamRunsSummary: + has_prior = self.prior_window_average is not None + has_change = self.change_vs_prior_window is not None + if has_prior != has_change: + raise ValueError( + "prior_window_average and change_vs_prior_window must both be " + "present or both be None" + ) + return self + + +class TeamRunsAnalysis(BaseModel): + """A team-season's run-scoring trend, ready to chart.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + team_id: int = Field(gt=0, description="MLB team id.") + team_name: str = Field(min_length=1, description="Historical name for the season.") + season: int = Field(gt=0, description="Season analysed.") + rolling_window: int = Field(ge=1, description="Games in the trailing window.") + points: tuple[TeamRunsPoint, ...] = Field( + min_length=1, description="Games in chart order." + ) + summary: TeamRunsSummary + + @model_validator(mode="after") + def _summary_matches_points(self) -> TeamRunsAnalysis: + if self.summary.games_played != len(self.points): + raise ValueError( + "summary.games_played must equal the number of chart points" + ) + return self + + @property + def last_game_date(self) -> date: + """Date of the most recent completed game in the analysis.""" + return self.points[-1].game_date + + +class LeagueRunsContext(BaseModel): + """MLB-wide run-scoring context for one season. + + Built from every persisted team-game batting line for the season, so + ``runs_per_game`` is a game-weighted mean across team-game records rather + than the unweighted mean of each club's own average. Teams do not all play + the same number of games, so those two numbers are not the same statistic. + + ``runs`` is required on every persisted team-game record, so unlike batting + strikeouts there is no unknown-total case to guard against here. + + Every field describes the games **currently stored** for the season. For a + season still being played that is the completed games held by the most + recent complete league-wide refresh, not a whole season. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + season: int = Field(gt=0, description="Season the context describes.") + teams_represented: int = Field( + ge=1, + description="Distinct teams with at least one stored game in the season.", + ) + team_game_records: int = Field( + ge=1, + description="Team-game batting lines counted. One MLB game contributes " + "two records once both clubs are stored, so this is not a game count.", + ) + total_runs: int = Field( + ge=0, description="Runs summed across every counted team-game record." + ) + runs_per_game: float = Field( + ge=0, + description="total_runs / team_game_records.", + ) + + @model_validator(mode="after") + def _runs_per_game_matches_the_totals(self) -> LeagueRunsContext: + expected = self.total_runs / self.team_game_records + if not isclose(self.runs_per_game, expected, rel_tol=1e-9, abs_tol=1e-9): + raise ValueError( + f"runs_per_game ({self.runs_per_game}) must equal total_runs / " + f"team_game_records ({expected})" + ) + if self.teams_represented > self.team_game_records: + raise ValueError( + f"teams_represented ({self.teams_represented}) cannot exceed " + f"team_game_records ({self.team_game_records})" + ) + return self + + +class TeamRunsLeagueComparison(BaseModel): + """One team's runs per game placed beside MLB overall for the same season. + + Purely descriptive. A difference here says the selected team scored more or + fewer runs per game than MLB across the stored season; it carries no claim + of significance, no park or opponent adjustment, and nothing about the runs + the team allowed. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + team_id: int = Field(gt=0, description="MLB team id of the selected team.") + team_name: str = Field(min_length=1, description="Name for the season.") + season: int = Field(gt=0, description="Season compared.") + team_runs_per_game: float = Field( + ge=0, + description="The selected team's average across its stored games, taken " + "from TeamRunsSummary.season_average so the page cannot disagree with " + "itself.", + ) + league: LeagueRunsContext = Field(description="MLB-wide context compared against.") + difference_vs_mlb: float = Field( + description="team_runs_per_game - league.runs_per_game. Positive means " + "the team scored more runs per game than MLB overall, negative fewer.", + ) + + @model_validator(mode="after") + def _comparison_is_internally_consistent(self) -> TeamRunsLeagueComparison: + if self.season != self.league.season: + raise ValueError( + f"season ({self.season}) must match the league context season " + f"({self.league.season})" + ) + expected = self.team_runs_per_game - self.league.runs_per_game + if not isclose(self.difference_vs_mlb, expected, rel_tol=1e-9, abs_tol=1e-9): + raise ValueError( + f"difference_vs_mlb ({self.difference_vs_mlb}) must equal " + f"team_runs_per_game - league.runs_per_game ({expected})" + ) + return self diff --git a/app/web/charts.py b/app/web/charts.py index e0244ed..52623ba 100644 --- a/app/web/charts.py +++ b/app/web/charts.py @@ -3,11 +3,11 @@ Kept out of the route so the figure contract can be tested without HTTP and so the route stays about request handling. -The hits and batting strikeout figures are built by separate functions that -share only the rendering helpers below. They look alike, but a single +The hits, batting strikeout, and runs figures are built by separate functions +that share only the rendering helpers below. They look alike, but a single parameterized builder would have to encode which labels, colours, and axis -semantics belong to which statistic, which is harder to read than two explicit -builders. +semantics belong to which statistic, which is harder to read than three +explicit builders. """ from datetime import date @@ -20,6 +20,8 @@ from app.schemas.analytics import ( TeamHitsAnalysis, TeamHitsLeagueComparison, + TeamRunsAnalysis, + TeamRunsLeagueComparison, TeamStrikeoutsAnalysis, TeamStrikeoutsLeagueComparison, ) @@ -41,6 +43,12 @@ RAW_STRIKEOUTS_TRACE_NAME = "Game Strikeouts" STRIKEOUTS_Y_AXIS_TITLE = "Batting Strikeouts per Game" +RUNS_CHART_DIV_ID = "team-runs-chart" +# Runs scored by the selected team. "Scored" is carried through the axis title +# so a per-game run number cannot be read as runs allowed or as a differential. +RAW_RUNS_TRACE_NAME = "Game Runs" +RUNS_Y_AXIS_TITLE = "Runs Scored per Game" + _NAVY = "#12263f" _TEAL = "#0f8b8d" # Distinct hue *and* distinct dash from the navy team line, so the two @@ -451,6 +459,169 @@ def build_team_strikeouts_figure( return figure +def build_team_runs_figure( + analysis: TeamRunsAnalysis, + league_comparison: TeamRunsLeagueComparison | None = None, +) -> go.Figure: + """Build the runs-per-game figure for one team-season. + + ``league_comparison`` adds a fourth trace, a horizontal MLB reference line, + drawn in the same amber dotted style the hits and batting strikeout charts + use so the three pages read as one application. It is optional: a season + without complete league coverage has no MLB average to draw, and the team's + own chart must still render. + """ + game_numbers = [point.season_game_number for point in analysis.points] + game_dates = [point.game_date for point in analysis.points] + runs = [point.runs for point in analysis.points] + rolling = [point.rolling_average for point in analysis.points] + # Each hover box shows date, matchup, runs, and rolling average regardless + # of which trace the pointer is over. + hover_data = [ + ( + format_long_date(point.game_date), + format_matchup(point.opponent_name, point.home_away), + point.runs, + point.rolling_average, + ) + for point in analysis.points + ] + rolling_name = rolling_average_trace_name(analysis.rolling_window) + hover_template = ( + "%{customdata[0]}
" + "%{customdata[1]}
" + "Runs: %{customdata[2]}
" + f"{analysis.rolling_window}-Game Avg: " + "%{customdata[3]:.2f}" + ) + + figure = go.Figure() + figure.add_trace( + go.Scatter( + x=game_numbers, + y=runs, + customdata=hover_data, + name=RAW_RUNS_TRACE_NAME, + mode="lines+markers", + line={"color": _RAW_LINE, "width": 1.2}, + # Open circles: the game markers sit on top of each other in a + # 162-game season, and an outline stays readable where filled + # dots merge into a blob. + marker={ + "size": 5, + "color": "rgba(0,0,0,0)", + "line": {"color": _RAW_MARKER, "width": 1.2}, + }, + hovertemplate=hover_template, + ) + ) + figure.add_trace( + go.Scatter( + x=game_numbers, + y=rolling, + customdata=hover_data, + name=rolling_name, + mode="lines", + # Straight segments between calculated points. A spline would + # overshoot between games and imply averages nobody calculated. + line={"color": _TEAL, "width": 3.5, "shape": "linear"}, + hovertemplate=hover_template, + ) + ) + season_average = analysis.summary.season_average + figure.add_trace( + go.Scatter( + x=[game_numbers[0], game_numbers[-1]], + y=[season_average, season_average], + name=TEAM_SEASON_AVERAGE_TRACE_NAME, + mode="lines", + line={"color": _NAVY, "width": 2, "dash": "dash"}, + hoverinfo="skip", + ) + ) + if league_comparison is not None: + mlb_average = league_comparison.league.runs_per_game + figure.add_trace( + go.Scatter( + x=[game_numbers[0], game_numbers[-1]], + y=[mlb_average, mlb_average], + name=MLB_AVERAGE_TRACE_NAME, + mode="lines", + line={"color": _AMBER, "width": 2, "dash": "dot"}, + hoverinfo="skip", + ) + ) + + # Only one of the two horizontal lines is labelled. They can sit within a + # tenth of a run of each other, and two labels there would overlap. + if league_comparison is None: + _label_reference_line( + figure, + x=game_numbers[-1], + y=season_average, + name=TEAM_SEASON_AVERAGE_TRACE_NAME, + ) + else: + _label_reference_line( + figure, + x=game_numbers[-1], + y=league_comparison.league.runs_per_game, + name=MLB_AVERAGE_TRACE_NAME, + ) + + tick_values, tick_labels = _season_game_ticks(game_numbers, game_dates) + figure.update_layout( + template="plotly_white", + margin=_MARGIN, + height=470, + hovermode="closest", + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font={"family": "system-ui, -apple-system, 'Segoe UI', sans-serif", "size": 13}, + legend={ + "orientation": "h", + "yanchor": "bottom", + "y": 1.04, + "xanchor": "center", + "x": 0.5, + "font": {"size": 12, "color": _AXIS_INK}, + }, + xaxis={ + "title": {"text": X_AXIS_TITLE, "standoff": 10, "font": _AXIS_TITLE_FONT}, + "tickfont": _TICK_FONT, + "tickmode": "array", + "tickvals": tick_values, + "ticktext": tick_labels, + # Only the horizontal gridlines are drawn: they are what a reader + # measures a value against, and vertical lines only add noise. + "showgrid": False, + "showline": True, + "linecolor": _AXIS_LINE, + "zeroline": False, + "rangemode": "tozero", + "automargin": True, + }, + yaxis={ + "title": { + "text": RUNS_Y_AXIS_TITLE, + "standoff": 10, + "font": _AXIS_TITLE_FONT, + }, + "tickfont": _TICK_FONT, + "gridcolor": _GRID, + "griddash": "dot", + "zeroline": False, + # Starts at zero like the other charts, and grows with the data. No + # fixed maximum: a 20-run blowout must still fit. + "rangemode": "tozero", + "tickformat": "d", + "dtick": 2, + "automargin": True, + }, + ) + return figure + + def render_figure_html(figure: go.Figure, *, div_id: str = CHART_DIV_ID) -> str: """Render a figure as an embeddable div. diff --git a/app/web/formatting.py b/app/web/formatting.py index 1a84ded..d7395e2 100644 --- a/app/web/formatting.py +++ b/app/web/formatting.py @@ -6,6 +6,8 @@ from app.schemas.analytics import ( TeamHitsAnalysis, TeamHitsLeagueComparison, + TeamRunsAnalysis, + TeamRunsLeagueComparison, TeamStrikeoutsAnalysis, TeamStrikeoutsLeagueComparison, ) @@ -13,6 +15,7 @@ HITS_PER_GAME_CAPTION = "Hits per Game" STRIKEOUTS_PER_GAME_CAPTION = "Batting Strikeouts per Game" +RUNS_PER_GAME_CAPTION = "Runs Scored per Game" NO_LEAGUE_COMPARISON_VALUE = "—" NO_LEAGUE_COMPARISON_CAPTION = "Comparison unavailable" LEAGUE_COMPARISON_UNAVAILABLE_NOTE = ( @@ -23,6 +26,10 @@ "MLB comparison unavailable. A complete league-season import is required " "before an MLB-wide batting strikeout average can be shown." ) +LEAGUE_RUNS_UNAVAILABLE_NOTE = ( + "MLB comparison unavailable. A complete league-season import is required " + "before an MLB-wide runs-per-game average can be shown." +) _MONTHS = ( "January", @@ -255,3 +262,80 @@ def format_league_strikeouts_backfill_note( f"are not presented as MLB overall. Re-import the league season to " f"backfill them: {reimport_command}" ) + + +def build_runs_summary_cards( + analysis: TeamRunsAnalysis, + league_comparison: TeamRunsLeagueComparison | None = None, +) -> list[SummaryCard]: + """Round the analysis for display only; the calculations keep full precision. + + The same four cards the hits and batting strikeout pages show. The third is + the team's difference against MLB. Without complete league coverage for the + season there is no MLB average to compare with, so the card reads ``—`` + rather than showing a number the data cannot support. It never reads + ``0.00``, which is a real value meaning the team matched MLB exactly. + + ``TeamRunsSummary`` still calculates the prior-window comparison; it is not + given a card, so the row keeps four cards rather than growing a fifth. + """ + window = analysis.rolling_window + summary = analysis.summary + + if league_comparison is None: + league_card = SummaryCard( + label="vs MLB", + value=NO_LEAGUE_COMPARISON_VALUE, + caption=NO_LEAGUE_COMPARISON_CAPTION, + ) + else: + league_card = SummaryCard( + label="vs MLB", + value=f"{league_comparison.difference_vs_mlb:+.2f}", + caption=RUNS_PER_GAME_CAPTION, + ) + + return [ + SummaryCard( + label=f"Recent {window}-Game Avg", + value=f"{summary.recent_average:.2f}", + caption=RUNS_PER_GAME_CAPTION, + ), + SummaryCard( + label="Season Avg", + value=f"{summary.season_average:.2f}", + caption=RUNS_PER_GAME_CAPTION, + ), + league_card, + SummaryCard( + label="Games Played", + value=f"{summary.games_played}", + caption="Completed Games", + ), + ] + + +def format_league_runs_note( + comparison: TeamRunsLeagueComparison | None, +) -> str: + """Explain the MLB run-scoring context on the page, or why there is none. + + The available wording deliberately says "currently stored" and names the + number of team-game records behind the average. Complete league coverage + means every team was refreshed, not that the season has finished being + played, and the sentence must not let a reader conclude otherwise. + + It also says "scored" so a per-game run number is not read as runs allowed. + """ + if comparison is None: + return LEAGUE_RUNS_UNAVAILABLE_NOTE + + league = comparison.league + return ( + f"MLB teams scored {league.runs_per_game:.2f} runs per game across the " + f"{league.team_game_records:,} team-game records currently stored for " + f"{league.season}, covering {league.teams_represented} teams — total " + f"runs divided by total team-game records, so a club that has played " + f"more games counts for more. Complete league coverage means every " + f"team was refreshed, not that the season has finished being played." + ) diff --git a/app/web/navigation.py b/app/web/navigation.py index a7a879e..559aaa8 100644 --- a/app/web/navigation.py +++ b/app/web/navigation.py @@ -1,9 +1,9 @@ """Links between the metric pages, keeping the reader's selection intact. -Moving from hits to batting strikeouts should not throw away the team, season, -and rolling window the reader chose, so each link carries them forward. Only -selections that are actually set are added, so a page that has no team yet -links to a plain path rather than one with empty parameters. +Moving between hits, batting strikeouts, and runs should not throw away the +team, season, and rolling window the reader chose, so each link carries them +forward. Only selections that are actually set are added, so a page that has no +team yet links to a plain path rather than one with empty parameters. """ from dataclasses import dataclass @@ -11,9 +11,11 @@ HITS_PATH = "/" STRIKEOUTS_PATH = "/strikeouts" +RUNS_PATH = "/runs" HITS_LABEL = "Hits" STRIKEOUTS_LABEL = "Batting Strikeouts" +RUNS_LABEL = "Runs" @dataclass(frozen=True) @@ -32,7 +34,7 @@ def build_nav_links( season: int | None = None, window: int | None = None, ) -> list[NavLink]: - """Build the navigation for both metric pages, preserving the selection.""" + """Build the navigation for every metric page, preserving the selection.""" selection: dict[str, int] = {} if team_id is not None: selection["team_id"] = team_id @@ -54,4 +56,9 @@ def build_nav_links( href=f"{STRIKEOUTS_PATH}{suffix}", is_current=current_path == STRIKEOUTS_PATH, ), + NavLink( + label=RUNS_LABEL, + href=f"{RUNS_PATH}{suffix}", + is_current=current_path == RUNS_PATH, + ), ] diff --git a/app/web/routes.py b/app/web/routes.py index 37a7652..f524566 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -17,6 +17,11 @@ compare_team_hits_to_league, supports_league_wide_average, ) +from app.analytics.league_runs import ( + build_league_runs_context, + compare_team_runs_to_league, + supports_league_wide_runs_average, +) from app.analytics.league_strikeouts import ( MissingLeagueStrikeoutDataError, build_league_strikeouts_context, @@ -24,6 +29,7 @@ supports_league_wide_strikeout_average, ) from app.analytics.team_hitting import DEFAULT_ROLLING_WINDOW, build_team_hits_analysis +from app.analytics.team_runs import build_team_runs_analysis from app.analytics.team_strikeouts import ( MissingStrikeoutDataError, build_team_strikeouts_analysis, @@ -40,12 +46,16 @@ from app.schemas.analytics import ( TeamHitsAnalysis, TeamHitsLeagueComparison, + TeamRunsAnalysis, + TeamRunsLeagueComparison, TeamStrikeoutsAnalysis, TeamStrikeoutsLeagueComparison, ) from app.web.charts import ( + RUNS_CHART_DIV_ID, STRIKEOUTS_CHART_DIV_ID, build_team_hits_figure, + build_team_runs_figure, build_team_strikeouts_figure, plotly_bundle_javascript, render_figure_html, @@ -53,14 +63,21 @@ ) from app.web.dependencies import get_db_session from app.web.formatting import ( + build_runs_summary_cards, build_strikeout_summary_cards, build_summary_cards, format_league_comparison_note, + format_league_runs_note, format_league_strikeouts_backfill_note, format_league_strikeouts_note, format_long_date, ) -from app.web.navigation import HITS_PATH, STRIKEOUTS_PATH, build_nav_links +from app.web.navigation import ( + HITS_PATH, + RUNS_PATH, + STRIKEOUTS_PATH, + build_nav_links, +) from app.web.selection import ( build_team_options, build_team_seasons_catalog, @@ -372,6 +389,111 @@ def strikeouts( request=request, name="strikeouts.html", context=context ) + @router.get(RUNS_PATH, response_class=HTMLResponse) + def runs( + request: Request, + session: Annotated[Session, Depends(get_db_session)], + team_id: Annotated[ + int | None, + Query(gt=0, description="MLB team id that has been imported locally."), + ] = None, + season: Annotated[ + int | None, + Query(gt=0, description="Season that has been imported for the team."), + ] = None, + window: Annotated[ + RollingWindowParam, + Query(description="Games in the trailing rolling average."), + ] = DEFAULT_ROLLING_WINDOW, + ) -> Response: + """Render run-scoring trends for one persisted team-season.""" + try: + available = list_available_team_seasons(session) + except DatabaseSchemaMissingError as exc: + return _render_schema_error(templates, request, settings, exc) + + teams = build_team_options(available) + context: dict[str, Any] = { + "app_name": settings.app_name, + "teams": teams, + "team_seasons_catalog": build_team_seasons_catalog(teams), + "window_options": ROLLING_WINDOW_OPTIONS, + "selected_window": window, + "selected_team": None, + "selected_season": None, + "import_command": IMPORT_COMMAND, + "plotly_bundle_path": PLOTLY_BUNDLE_PATH, + "mlb_logo_url": MLB_LOGO_URL, + "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX, + "form_action": RUNS_PATH, + "nav_links": build_nav_links( + current_path=RUNS_PATH, + team_id=team_id, + season=season, + window=window, + ), + } + + if not teams: + context["state"] = "empty" + return templates.TemplateResponse( + request=request, name="runs.html", context=context + ) + + selected_team = select_team(teams, team_id) + if selected_team is None: + context["state"] = "not_found" + context["not_found_message"] = ( + f"No games are stored for team id {team_id}. " + "Pick a team that has been imported, or import that team." + ) + return templates.TemplateResponse( + request=request, name="runs.html", context=context, status_code=404 + ) + + context["selected_team"] = selected_team + selected_season = select_season(selected_team, season) + if selected_season is None: + context["state"] = "not_found" + context["not_found_message"] = ( + f"No {season} games are stored for {selected_team.team_name}. " + f"Stored seasons: " + f"{', '.join(str(value) for value in selected_team.seasons)}." + ) + return templates.TemplateResponse( + request=request, name="runs.html", context=context, status_code=404 + ) + + context["selected_season"] = selected_season + context["nav_links"] = build_nav_links( + current_path=RUNS_PATH, + team_id=selected_team.team_id, + season=selected_season, + window=window, + ) + games = list_team_season( + session, team_id=selected_team.team_id, season=selected_season + ) + analysis = build_team_runs_analysis(games, rolling_window=window) + league_comparison = _load_league_runs_comparison(session, analysis) + figure = build_team_runs_figure(analysis, league_comparison) + + context.update( + { + "state": "ok", + "analysis": analysis, + "chart_html": render_figure_html(figure, div_id=RUNS_CHART_DIV_ID), + "rolling_average_label": rolling_average_trace_name(window), + "summary_cards": build_runs_summary_cards(analysis, league_comparison), + "league_comparison": league_comparison, + "league_comparison_note": format_league_runs_note(league_comparison), + "data_through": format_long_date(analysis.last_game_date), + } + ) + return templates.TemplateResponse( + request=request, name="runs.html", context=context + ) + @router.get(PLOTLY_BUNDLE_PATH, include_in_schema=False) def plotly_bundle() -> Response: """Serve the plotly.js bundle from the installed package. @@ -449,6 +571,34 @@ def _load_league_strikeouts_comparison( return compare_team_strikeouts_to_league(analysis, league) +def _load_league_runs_comparison( + session: Session, + analysis: TeamRunsAnalysis, +) -> TeamRunsLeagueComparison | None: + """Read MLB run-scoring context, or None when it is not earned. + + The completeness rule and the formula both live in + ``app.analytics.league_runs``; this only wires the persisted coverage state + and the persisted season to them. A season without complete coverage simply + yields None, and the team's own page renders exactly as it would without + any MLB context. + + Runs need no equivalent of the batting strikeout backfill path: ``runs`` is + required on every persisted team-game record, so a covered season cannot be + holding unknown totals. + + The season query cannot come back empty here: the analysis was built from + games stored for this season, so those rows are part of what it returns. + """ + coverage = get_league_season_ingestion(session, season=analysis.season) + if not supports_league_wide_runs_average(coverage): + return None + + league_games = list_league_season(session, season=analysis.season) + league = build_league_runs_context(league_games) + return compare_team_runs_to_league(analysis, league) + + def _render_schema_error( templates: Jinja2Templates, request: Request, diff --git a/app/web/templates/runs.html b/app/web/templates/runs.html new file mode 100644 index 0000000..df7db53 --- /dev/null +++ b/app/web/templates/runs.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} + +{% block title %} + {%- if state == "ok" -%} + {{ analysis.team_name }} {{ analysis.season }} Run Scoring Trends + {%- else -%} + Team Run Scoring Trends + {%- endif -%} +{% endblock %} + +{% block head %} + {# plotly.js must be parsed before the figure div's inline bootstrap script. #} + {% if state == "ok" %} + + {% endif %} + {% if state != "empty" %} + + {% endif %} +{% endblock %} + +{% block content %} +
+

Team Run Scoring Trends

+

+ See how many runs a team is scoring per game as the season progresses. +

+
+ + {% if state == "empty" %} +
+

No team data has been imported yet

+

Import a team-season, then reload this page:

+
{{ import_command }}
+
+ {% else %} + {% include "_selector_form.html" %} + + {% if state == "not_found" %} +
+

That team-season is not stored locally

+

{{ not_found_message }}

+

Import it with:

+
{{ import_command }}
+
+ {% else %} +
+
+

{{ analysis.team_name }} — Runs Scored per Game

+

{{ analysis.season }} regular season

+
+
{{ chart_html | safe }}
+
+ +
+ {% for card in summary_cards %} +
+

{{ card.label }}

+

{{ card.value }}

+

{{ card.caption }}

+
+ {% endfor %} +
+ +
+ +
+

About this chart

+

+ Each point is the number of runs {{ analysis.team_name }} scored in + one completed game. These are runs scored, not runs allowed, and + nothing here is a run differential. The + {{ rolling_average_label|lower }} covers that game and the + {{ analysis.rolling_window - 1 }} games before it, so it shows the + recent scoring trend; early-season points use every game played so + far. The dashed line is the team's average across the completed + games currently stored for this season. +

+

+ {% if league_comparison %} + The dotted line is MLB overall. + {% endif %} + {{ league_comparison_note }} + {% if league_comparison %} + A positive vs MLB value means + {{ analysis.team_name }} scored more runs per game than MLB + across the stored season, and a negative value fewer. It is + descriptive context, not a measure of significance, and it makes + no claim about why the two numbers differ. + {% endif %} +

+
+
+ {% endif %} + {% endif %} +{% endblock %} + +{% block footer %} + {% if state == "ok" %} + Data through {{ data_through }} + {% endif %} + Source: MLB Stats API via python-mlb-statsapi +{% endblock %} diff --git a/docs/team-runs-visualization.md b/docs/team-runs-visualization.md new file mode 100644 index 0000000..5aaefc5 --- /dev/null +++ b/docs/team-runs-visualization.md @@ -0,0 +1,359 @@ +# Team runs per game visualization + +This document describes the `/runs` page added by issue #24: what it measures, +how the two averages on it are calculated, why the MLB average uses the +denominator it does, and when the application is allowed to call that average +"MLB" at all. + +It answers one question: + +> How many runs per game is this team scoring, how is that changing over the +> season, and how does its season average compare with MLB overall? + +Related documents: + +- [team-hits-visualization.md](team-hits-visualization.md) — the first metric + page, whose layout and rolling-average behavior this one reuses +- [team-strikeouts-visualization.md](team-strikeouts-visualization.md) — the + second metric page +- [team-vs-mlb-comparison.md](team-vs-mlb-comparison.md) — where the + game-weighted league definition and the coverage rule were established +- [league-season-ingestion.md](league-season-ingestion.md) — the coverage state + this page reads + +Throughout this document and the UI, "runs" means **runs scored** by the +selected team. Runs allowed and run differential are different statistics and +are not calculated anywhere on this page. + +## 1. Source data field + +No new MLB request, no new column, and no migration were added for this page. + +`TeamGameBattingLine.runs` has been persisted since Milestone 2, as a required +non-negative integer: + +| Layer | Definition | +| --- | --- | +| Domain schema | `runs: int = Field(ge=0, ...)` | +| ORM column | `runs: Mapped[int] = mapped_column(Integer, nullable=False)` | +| Table constraint | `CHECK (runs >= 0)`, named `runs_nonnegative` | + +Both migrations that have ever created the table (`166b6424e4f9` and the +`94dec6973c80` rebuild that added batting strikeouts) declare the column +`nullable=False` with that check constraint. There is therefore no historical +row holding an unknown run total, and this page needs none of the +nullable-data machinery batting strikeouts required. + +That difference is worth stating plainly, because the two metrics look alike +and their data histories are not: + +| | Hits | Batting strikeouts | Runs | +| --- | --- | --- | --- | +| Nullable on stored rows | no | **yes** (pre-3.5 rows) | no | +| Page can hit a backfill state | no | **yes** (HTTP 409) | no | +| League context can be refused for unknown values | no | **yes** | no | + +## 2. Team Runs/Game formula + +```text +Team Runs/Game = total runs scored by the team across its stored completed games + ────────────────────────────────────────────────────────────── + number of stored completed team-game records for that season +``` + +Implemented in `app/analytics/team_runs.py::build_team_runs_analysis`, which +returns a `TeamRunsAnalysis` carrying a `TeamRunsSummary`. + +`TeamRunsSummary.season_average` is the **single authoritative** team average. +Three places on the page display it, and all three read this one field: + +1. the **Season Avg** summary card +2. the chart's dashed **Team Season Average** reference line +3. the team side of the MLB comparison + +`compare_team_runs_to_league` takes `analysis.summary.season_average` rather +than recalculating from the points, so the card and the chart cannot drift +apart. `TeamRunsAnalysis` also validates that `summary.games_played` equals the +number of chart points, so a summary describing a different set of games than +the chart cannot be constructed. + +### What the denominator is + +Stored completed team-game records for the selected team-season — not 162, and +not a scheduled-game count. A season still being played, or a partial import, +divides by the games actually held. The page says "the completed games +currently stored for this season" rather than implying a finished season, and +the footer carries a "Data through" date. + +## 3. Rolling average + +Unchanged from the hits and batting strikeout pages, deliberately. Issue #24 +introduced no new smoothing method. + +Games are ordered by `(game_date, game_number, game_pk)`, so both halves of a +doubleheader keep their real sequence and a tie on date and game number still +resolves deterministically. The chart's x axis is `season_game_number`, a +continuous 1-based index over that order. + +For each game, the value plotted is the **trailing** mean of the `window` most +recent games up to and including that game: + +```text +rolling_average[i] = mean(runs[max(0, i - window + 1) .. i]) +``` + +Early-season points use every game played so far rather than disappearing until +`window` games exist, so game 1 of a season is its own average. The rolling +line joins calculated points with straight segments: **no spline +interpolation**, because a spline would draw averages between games that were +never calculated. + +## 4. MLB Runs/Game formula + +```text +MLB Runs/Game = total runs across all persisted team-game records for the season + ─────────────────────────────────────────────────────────────── + total persisted team-game records for that season +``` + +Implemented in `app/analytics/league_runs.py::build_league_runs_context`, +returning a `LeagueRunsContext`: + +| Field | Meaning | +| --- | --- | +| `season` | The season every counted record belongs to | +| `teams_represented` | Distinct `team_id`s with at least one stored game | +| `team_game_records` | Team-game batting lines counted | +| `total_runs` | Runs summed across those records | +| `runs_per_game` | `total_runs / team_game_records` | + +`LeagueRunsContext` re-derives `runs_per_game` from the two totals in a +validator, so a context holding an average nothing in it produced cannot be +constructed — by this code or by any future caller. + +### Why the denominator is team-game records + +One real MLB game produces **two** team batting lines, one per club. The +denominator counts those lines, not games. Both sides of the comparison are +therefore per-team per-game numbers, which is what makes the subtraction in +section 6 meaningful: a team's own average is also per-team per-game. + +Because each club's runs appear once, as that club's own total and never as its +opponent's, nothing here implies a run differential. + +### Why it is game-weighted, and not the mean of team averages + +Every stored team-game record counts once, so a club that has played more games +contributes proportionally more. That is what "MLB overall" means. + +The worked example from the issue: + +```text +Team A: 5 runs, 3 runs +Team B: 2 runs + +game-weighted : (5 + 3 + 2) / 3 == 3.333... <- correct +mean of averages : ((5 + 3) / 2 + 2)/2 == 3.0 <- wrong +``` + +The two agree only when every club has played the same number of games. During +a season they never do — off days, doubleheaders, and postponements guarantee +it — and a partially imported season can differ far more. Averaging each club's +own average would silently give a team with 40 games the same weight as a team +with 162, which answers a different question. + +`tests/test_analytics_league_runs.py::test_unequal_team_game_counts_are_weighted_by_games_played` +pins this exact example, and +`test_unequal_game_counts_are_weighted_on_the_page` pins it end to end through +the rendered HTML. + +### No hardcoded league shape + +Nothing in the calculation assumes 30 teams, 162 games, 2,430 MLB games, or +4,860 team-game records. Every number comes from the records actually stored. +An in-progress season with 40 stored records divides by 40. + +## 5. Coverage semantics + +The league average is shown only when `LeagueSeasonIngestionStatus.COMPLETE` is +recorded for the season. `supports_league_wide_runs_average` delegates to +`app.analytics.league_hitting.supports_league_wide_average` rather than +re-implementing the rule, so the three metric pages cannot disagree about +whether a season qualifies. + +| Coverage state | MLB Average line | `vs MLB` card | Team chart | +| --- | --- | --- | --- | +| `COMPLETE` | drawn | signed number | renders | +| `INCOMPLETE` | not drawn | `—` | renders | +| `RUNNING` | not drawn | `—` | renders | +| no coverage record | not drawn | `—` | renders | + +Two rules matter here and are easy to reverse by accident: + +**Completeness is never inferred from record counts.** A row count cannot tell +a full season from a season missing a club, and it certainly cannot tell either +from a season still being played. Only the recorded coverage state counts. + +**`COMPLETE` describes the refresh, not the season.** It means the latest +league-wide run discovered every MLB team for that season and successfully +ingested all of them. It does **not** mean the baseball season has ended. + +### In-progress seasons + +It follows that an in-progress season — 2026, say — with `COMPLETE` coverage +**is** allowed to show an MLB Runs/Game comparison, calculated from the +completed games currently persisted. That is the intended behavior, not a +loophole. + +What the number describes in that case is "MLB across the games stored so far", +and the page's wording says exactly that: it names the record count and the +team count behind the average, says "currently stored", and states that +complete league coverage means every team was refreshed, "not that the season +has finished being played." + +### The team page never depends on the comparison + +A missing or untrusted league context yields `None` and nothing more. The +chart, the rolling average, the season average, and the other three cards are +unaffected. League-comparison unavailability is never an error state for this +page. + +## 6. What `vs MLB` means + +```text +difference_vs_mlb = team season Runs/Game - MLB Runs/Game +``` + +For example, a team at 4.75 against an MLB average of 4.42 reads `+0.33`. + +- **Positive** — the selected team scored more runs per game than MLB overall + across the stored season. +- **Negative** — fewer. +- **`+0.00`** — matched MLB exactly. This is a real result and is deliberately + distinct from unavailable, which renders as `—`. The card never shows `0.00` + to mean "no data". + +This is descriptive subtraction and nothing more. It is deliberately **not**: + +- a rank or a percentile +- a test of significance +- park-adjusted or opponent-adjusted +- an expected-runs or run-creation model +- a run differential +- a correlation, or any claim about cause + +Any of those would need its own definition, its own data, and its own +milestone. `TeamRunsLeagueComparison` re-derives the difference from its two +inputs in a validator, so no caller can hand the page a number the subtraction +did not produce. + +## 7. The page renders from the database only + +`/runs` reads SQLite and nothing else. It loads the selected team-season with +`list_team_season`, and — only when coverage permits — the whole season with +`list_league_season`. Both are existing repository functions; no new query was +added, and no Runs/Game formula lives in the repository layer. + +The MLB Stats API is reached exclusively from the import CLI. Automated tests +assert this directly: `test_the_runs_page_never_calls_the_mlb_api` and +`test_the_comparison_never_reaches_the_mlb_api` monkeypatch the HTTP session, +the MLB client constructor, and the ingestion services to raise, then render +the page. + +The one network dependency in the rendered HTML is decorative: club and league +marks are fetched by the browser from MLB's public logo host. Every page names +its team in text and the layout holds when those images do not load. + +## 8. Architecture + +```text +app/schemas/analytics.py TeamRunsPoint, TeamRunsSummary, TeamRunsAnalysis, + LeagueRunsContext, TeamRunsLeagueComparison +app/analytics/team_runs.py the team trend and season average +app/analytics/league_runs.py the MLB average, the coverage rule, the comparison +app/web/charts.py build_team_runs_figure +app/web/formatting.py build_runs_summary_cards, format_league_runs_note +app/web/routes.py the thin /runs route +app/web/templates/runs.html the page +``` + +Both analytics modules are pure: no FastAPI, no Jinja, no SQLAlchemy, no +Plotly, no MLB client, no network. They take normalized domain records and +return typed results, and they are testable without a web server or a database. + +### Why three implementations instead of one framework + +Hits, batting strikeouts, and runs are now three near-identical modules. That +duplication is intentional and is called out in `AGENTS.md`: a shared +`GenericMetricAnalysis`/`MetricConfig` layer would have to encode which labels, +axis semantics, colours, nullability rules, and error states belong to which +statistic, and would be harder to read than three explicit implementations. + +The three are not interchangeable. Batting strikeouts can be unknown on a +stored row and runs cannot. More hits is not the same kind of fact as more +batting strikeouts. The right time to abstract is after a repeated pattern has +proven stable in real use, not at the third occurrence. + +Two things *are* shared, because sharing them prevents a real bug rather than +saving typing: the coverage rule (`supports_league_wide_average`) and the chart +rendering helpers. One coverage rule means the pages cannot disagree about +whether a season is MLB-wide. + +## 9. Chart + +| Trace | Style | Meaning | +| --- | --- | --- | +| Game Runs | thin line, open circle markers | runs scored in each completed game | +| *N*-Game Average | thick teal, linear segments | the rolling trend | +| Team Season Average | navy dashed horizontal | this club's stored-season average | +| MLB Average | amber dotted horizontal | MLB overall, only when coverage is `COMPLETE` | + +The MLB treatment — amber, dotted — matches the hits and batting strikeout +pages, so the three read as one application. Only one of the two horizontal +lines carries a value label: they can sit within a tenth of a run of each +other, where two labels would overlap. When MLB context is present it is the +labelled one, since that is the line a reader is comparing against. + +The y axis starts at zero, grows with the data, and has no hardcoded maximum — +a 20-run blowout must still fit. + +## 10. Summary cards + +The same four-card row as the other metric pages: + +| Card | Example | Source | +| --- | --- | --- | +| Recent *N*-Game Avg | `4.87` | `summary.recent_average` | +| Season Avg | `4.62` | `summary.season_average` | +| vs MLB | `+0.21`, or `—` | `comparison.difference_vs_mlb` | +| Games Played | `162` | `summary.games_played` | + +`TeamRunsSummary` still calculates the prior-window comparison; it is not given +a card, so the row keeps four cards rather than growing a fifth. + +## 11. Limitations + +**Runs are a team outcome, not a measure of hitting alone.** A run requires +getting on base and being driven in, and it is shaped by opposing pitching and +defense, by ballpark, by sequencing, and by luck. Runs/Game describes what the +scoreboard said, not how well the offense hit. + +**No context is adjusted.** Coors Field and a marine-layer night in Seattle +count the same. Strength of schedule counts the same. A `+0.33` difference does +not mean the club's offense is 0.33 runs per game better than average in any +adjusted sense. + +**Extra innings inflate a game's runs.** Runs/Game is a per-game count, not a +per-inning or per-opportunity rate, and a 14-inning game had more chances to +score than a rain-shortened one. + +**Every number describes stored games.** For an in-progress season or a partial +import, that is a subset of the season, and both the team and MLB averages move +as more games are imported. + +**The comparison says nothing about run prevention.** A club can score more +runs per game than MLB and still be outscored. Run differential is a different +statistic and is not shown. + +**No significance is claimed.** Two clubs 0.05 runs per game apart are not +meaningfully distinguished by this page, and it does not pretend otherwise. diff --git a/tests/factories.py b/tests/factories.py index ab9a32a..5c339a5 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -4,7 +4,11 @@ from datetime import date, timedelta from typing import Any -from app.schemas.analytics import LeagueHitsContext, LeagueStrikeoutsContext +from app.schemas.analytics import ( + LeagueHitsContext, + LeagueRunsContext, + LeagueStrikeoutsContext, +) from app.schemas.games import TeamGameBattingLine MARINERS_ID = 136 @@ -47,6 +51,7 @@ def make_season( season: int = 2025, start_date: date | None = None, strikeouts: Sequence[int | None] | None = None, + runs: Sequence[int] | None = None, ) -> list[TeamGameBattingLine]: """Build one game per hit total, on consecutive days, in season order. @@ -56,11 +61,17 @@ def make_season( ``strikeouts`` defaults to None for every game, which is what a row persisted before Milestone 3.5 looks like. Pass one value per game to build a team-season that has been imported with batting strikeouts. + + ``runs`` defaults to the batting line's own run total for every game. + Unlike strikeouts there is no unset case to model: ``runs`` is required on + every persisted record. Pass one value per game to choose the totals. """ if strikeouts is not None and len(strikeouts) != len(hits): raise ValueError( f"strikeouts has {len(strikeouts)} values but hits has {len(hits)}" ) + if runs is not None and len(runs) != len(hits): + raise ValueError(f"runs has {len(runs)} values but hits has {len(hits)}") opening_day = start_date or date(season, OPENING_DAY.month, OPENING_DAY.day) return [ make_batting_line( @@ -72,6 +83,7 @@ def make_season( home_away="home" if index % 2 == 0 else "away", hits=value, strikeouts=None if strikeouts is None else strikeouts[index], + **({} if runs is None else {"runs": runs[index]}), ) for index, value in enumerate(hits) ] @@ -119,3 +131,25 @@ def make_league_strikeouts_context( total_strikeouts=total_strikeouts, strikeouts_per_game=total_strikeouts / team_game_records, ) + + +def make_league_runs_context( + *, + season: int = 2025, + total_runs: int = 45, + team_game_records: int = 10, + teams_represented: int = 2, +) -> LeagueRunsContext: + """Build MLB-wide run context directly, for tests about presentation. + + Tests of the formula itself build the context from batting lines through + ``build_league_runs_context``. Tests about cards, traces, and wording only + need a context holding a chosen average, so they build one here. + """ + return LeagueRunsContext( + season=season, + teams_represented=teams_represented, + team_game_records=team_game_records, + total_runs=total_runs, + runs_per_game=total_runs / team_game_records, + ) diff --git a/tests/test_analytics_league_runs.py b/tests/test_analytics_league_runs.py new file mode 100644 index 0000000..9e2839e --- /dev/null +++ b/tests/test_analytics_league_runs.py @@ -0,0 +1,306 @@ +"""Tests for MLB-wide run analytics and the coverage rule that gates them. + +Every case here is offline, built from normalized batting lines and from +coverage states constructed directly. Coverage is what decides whether a season +may be described as MLB-wide; a record count never is. +""" + +from datetime import datetime + +import pytest + +from app.analytics.league_runs import ( + LeagueRunsAnalysisError, + build_league_runs_context, + compare_team_runs_to_league, + supports_league_wide_runs_average, +) +from app.analytics.team_runs import build_team_runs_analysis +from app.schemas.analytics import LeagueRunsContext +from app.schemas.ingestion import ( + LeagueSeasonIngestionState, + LeagueSeasonIngestionStatus, +) +from tests.factories import ( + MARINERS_ID, + MARINERS_NAME, + TWINS_ID, + TWINS_NAME, + make_league_runs_context, + make_season, +) + +ANGELS_ID = 108 +ANGELS_NAME = "Los Angeles Angels" + + +def coverage( + status: LeagueSeasonIngestionStatus, + *, + season: int = 2025, +) -> LeagueSeasonIngestionState: + """Build a persisted coverage state in one of its three real shapes.""" + started = datetime(2026, 3, 1, 12, 0, 0) + if status is LeagueSeasonIngestionStatus.RUNNING: + return LeagueSeasonIngestionState( + season=season, + status=status, + expected_team_count=30, + successful_team_count=0, + failed_team_count=0, + started_at=started, + ) + failed = 0 if status is LeagueSeasonIngestionStatus.COMPLETE else 1 + return LeagueSeasonIngestionState( + season=season, + status=status, + expected_team_count=30, + successful_team_count=30 - failed, + failed_team_count=failed, + started_at=started, + completed_at=datetime(2026, 3, 1, 12, 30, 0), + ) + + +def league_games( + runs: list[int], + *, + team_id: int = MARINERS_ID, + team_name: str = MARINERS_NAME, + season: int = 2025, +): + """Build one team's stored season with the given per-game run totals.""" + return make_season( + hits=[8] * len(runs), + runs=runs, + team_id=team_id, + team_name=team_name, + season=season, + ) + + +def team_analysis(runs: list[int], *, window: int = 2, season: int = 2025): + return build_team_runs_analysis( + league_games(list(runs), season=season), rolling_window=window + ) + + +# ---------------------------------------------------------------- the formula + + +def test_mlb_runs_per_game_is_total_runs_over_total_team_game_records() -> None: + context = build_league_runs_context(league_games([5, 3, 4])) + assert context.total_runs == 12 + assert context.team_game_records == 3 + assert context.runs_per_game == pytest.approx(4.0) + + +def test_unequal_team_game_counts_are_weighted_by_games_played() -> None: + """The worked example from issue #24, and the distinction it protects. + + Team A scores 5 and 3. Team B scores 2 in its only game. + + game-weighted : (5 + 3 + 2) / 3 == 3.333... <- what this must be + mean of averages : ((5 + 3) / 2 + 2) / 2 == 3.0 + """ + games = [ + *league_games([5, 3], team_id=MARINERS_ID, team_name=MARINERS_NAME), + *league_games([2], team_id=TWINS_ID, team_name=TWINS_NAME), + ] + context = build_league_runs_context(games) + assert context.team_game_records == 3 + assert context.runs_per_game == pytest.approx(10 / 3) + assert context.runs_per_game != pytest.approx(3.0) + + +def test_a_club_with_more_games_pulls_the_average_further() -> None: + games = [ + *league_games([6] * 100, team_id=MARINERS_ID, team_name=MARINERS_NAME), + *league_games([0], team_id=TWINS_ID, team_name=TWINS_NAME), + ] + context = build_league_runs_context(games) + assert context.runs_per_game == pytest.approx(600 / 101) + assert context.runs_per_game > 5.9 + + +def test_the_denominator_is_team_game_records_not_mlb_games() -> None: + """Two clubs with 20 stored games each is 40 records, not 20 games.""" + games = [ + *league_games([4] * 20, team_id=MARINERS_ID, team_name=MARINERS_NAME), + *league_games([6] * 20, team_id=TWINS_ID, team_name=TWINS_NAME), + ] + context = build_league_runs_context(games) + assert context.team_game_records == 40 + assert context.runs_per_game == pytest.approx(5.0) + + +def test_several_teams_in_one_season_are_accepted() -> None: + games = [ + *league_games([5, 5], team_id=MARINERS_ID, team_name=MARINERS_NAME), + *league_games([3, 7], team_id=TWINS_ID, team_name=TWINS_NAME), + *league_games([4, 0], team_id=ANGELS_ID, team_name=ANGELS_NAME), + ] + context = build_league_runs_context(games) + assert context.teams_represented == 3 + assert context.team_game_records == 6 + assert context.total_runs == 24 + assert context.runs_per_game == pytest.approx(4.0) + assert context.season == 2025 + + +def test_a_shutout_counts_as_a_game_with_zero_runs() -> None: + context = build_league_runs_context(league_games([0, 6, 3])) + assert (context.total_runs, context.team_game_records) == (9, 3) + assert context.runs_per_game == pytest.approx(3.0) + + +def test_a_partial_season_is_averaged_over_the_records_it_holds() -> None: + """An in-progress season divides by its own record count, not 162 or 4,860.""" + games = [ + *league_games([5] * 40, season=2026, team_id=MARINERS_ID), + *league_games([3] * 38, season=2026, team_id=TWINS_ID, team_name=TWINS_NAME), + ] + context = build_league_runs_context(games) + assert context.team_game_records == 78 + assert context.runs_per_game == pytest.approx((5 * 40 + 3 * 38) / 78) + + +def test_mixed_seasons_are_rejected() -> None: + games = [ + *league_games([4], season=2025), + *league_games([5], season=2026), + ] + with pytest.raises(LeagueRunsAnalysisError, match="one season"): + build_league_runs_context(games) + + +def test_empty_input_is_rejected() -> None: + """No records means no MLB average, not an average of nothing.""" + with pytest.raises(LeagueRunsAnalysisError, match="no team-game records"): + build_league_runs_context([]) + + +def test_the_context_refuses_an_average_its_totals_do_not_produce() -> None: + """No caller, now or later, can hand the page an unrelated MLB number.""" + with pytest.raises(ValueError, match="runs_per_game"): + LeagueRunsContext( + season=2025, + teams_represented=2, + team_game_records=10, + total_runs=45, + runs_per_game=9.9, + ) + + +def test_more_teams_than_records_is_refused_by_the_context() -> None: + """A team cannot be represented without at least one stored record.""" + with pytest.raises(ValueError, match="teams_represented"): + make_league_runs_context(teams_represented=30, team_game_records=10) + + +# ------------------------------------------------------------- the comparison + + +def test_a_team_scoring_more_than_mlb_gets_a_positive_difference() -> None: + """The worked example from the issue: 4.75 against 4.42 reads +0.33.""" + analysis = team_analysis([5, 5, 5, 4]) + league = make_league_runs_context(total_runs=442, team_game_records=100) + result = compare_team_runs_to_league(analysis, league) + assert result.team_runs_per_game == pytest.approx(4.75) + assert result.difference_vs_mlb == pytest.approx(0.33) + + +def test_a_team_scoring_less_than_mlb_gets_a_negative_difference() -> None: + analysis = team_analysis([4, 4, 4, 4]) + league = make_league_runs_context(total_runs=442, team_game_records=100) + result = compare_team_runs_to_league(analysis, league) + assert result.difference_vs_mlb == pytest.approx(-0.42) + + +def test_a_team_matching_mlb_reads_as_a_real_zero() -> None: + """0.00 means matched exactly; unavailable is a separate state entirely.""" + analysis = team_analysis([4, 4]) + league = make_league_runs_context(total_runs=400, team_game_records=100) + assert compare_team_runs_to_league(analysis, league).difference_vs_mlb == 0.0 + + +def test_the_comparison_reuses_the_team_season_average_it_was_given() -> None: + """One team average on the page, so the card and the chart cannot disagree.""" + analysis = team_analysis([1, 2, 3, 12]) + result = compare_team_runs_to_league(analysis, make_league_runs_context()) + assert result.team_runs_per_game == analysis.summary.season_average + + +def test_the_comparison_carries_the_team_identity_and_league_context() -> None: + analysis = team_analysis([4] * 4) + league = make_league_runs_context( + teams_represented=30, team_game_records=100, total_runs=442 + ) + result = compare_team_runs_to_league(analysis, league) + assert (result.team_id, result.team_name) == (MARINERS_ID, MARINERS_NAME) + assert result.season == 2025 + assert result.league == league + + +def test_comparing_across_seasons_is_rejected() -> None: + analysis = team_analysis([4] * 4, season=2026) + league = make_league_runs_context(season=2025) + with pytest.raises(LeagueRunsAnalysisError, match="2026"): + compare_team_runs_to_league(analysis, league) + + +def test_the_comparison_against_an_unequal_league_stays_game_weighted() -> None: + """End to end: the +3.00 an unweighted mean would produce never appears.""" + games = [ + *league_games([5, 3], team_id=MARINERS_ID, team_name=MARINERS_NAME), + *league_games([2], team_id=TWINS_ID, team_name=TWINS_NAME), + ] + league = build_league_runs_context(games) + analysis = build_team_runs_analysis( + league_games([5, 3], team_id=MARINERS_ID, team_name=MARINERS_NAME), + rolling_window=2, + ) + result = compare_team_runs_to_league(analysis, league) + assert result.difference_vs_mlb == pytest.approx(4.0 - 10 / 3) + assert result.difference_vs_mlb != pytest.approx(1.0) + + +# ---------------------------------------------------------- the coverage rule + + +def test_complete_coverage_allows_a_comparison() -> None: + assert supports_league_wide_runs_average( + coverage(LeagueSeasonIngestionStatus.COMPLETE) + ) + + +@pytest.mark.parametrize( + "status", + [LeagueSeasonIngestionStatus.INCOMPLETE, LeagueSeasonIngestionStatus.RUNNING], +) +def test_other_coverage_states_refuse_a_comparison( + status: LeagueSeasonIngestionStatus, +) -> None: + assert not supports_league_wide_runs_average(coverage(status)) + + +def test_a_season_with_no_coverage_record_refuses_a_comparison() -> None: + assert not supports_league_wide_runs_average(None) + + +def test_complete_coverage_of_an_in_progress_season_still_allows_it() -> None: + """COMPLETE describes the refresh, not the season being over.""" + assert supports_league_wide_runs_average( + coverage(LeagueSeasonIngestionStatus.COMPLETE, season=2026) + ) + + +def test_the_runs_coverage_rule_is_the_shared_one() -> None: + """One rule across the metric pages, so they cannot disagree on a season.""" + from app.analytics.league_hitting import supports_league_wide_average + + for status in LeagueSeasonIngestionStatus: + state = coverage(status) + assert supports_league_wide_runs_average(state) == supports_league_wide_average( + state + ) diff --git a/tests/test_analytics_schemas.py b/tests/test_analytics_schemas.py index f594f90..d9104c7 100644 --- a/tests/test_analytics_schemas.py +++ b/tests/test_analytics_schemas.py @@ -7,10 +7,15 @@ from app.schemas.analytics import ( LeagueHitsContext, + LeagueRunsContext, TeamHitsAnalysis, TeamHitsLeagueComparison, TeamHitsPoint, TeamHitsSummary, + TeamRunsAnalysis, + TeamRunsLeagueComparison, + TeamRunsPoint, + TeamRunsSummary, TeamStrikeoutsAnalysis, TeamStrikeoutsPoint, TeamStrikeoutsSummary, @@ -335,3 +340,192 @@ def test_comparison_allows_a_negative_difference() -> None: difference_vs_mlb=7.0 - 39852 / 4860, ) assert comparison.difference_vs_mlb < 0 + + +# ------------------------------------------------------------ runs schemas + + +def make_runs_point(**overrides: object) -> TeamRunsPoint: + base: dict[str, object] = { + "game_pk": 776000, + "game_number": 1, + "season_game_number": 1, + "game_date": date(2025, 3, 27), + "opponent_name": "Minnesota Twins", + "home_away": "home", + "runs": 4, + "rolling_average": 4.0, + } + base.update(overrides) + return TeamRunsPoint(**base) + + +def make_runs_summary(**overrides: object) -> TeamRunsSummary: + base: dict[str, object] = { + "games_played": 1, + "season_average": 4.0, + "recent_average": 4.0, + } + base.update(overrides) + return TeamRunsSummary(**base) + + +def make_runs_analysis(**overrides: object) -> TeamRunsAnalysis: + base: dict[str, object] = { + "team_id": 136, + "team_name": "Seattle Mariners", + "season": 2025, + "rolling_window": 15, + "points": (make_runs_point(),), + "summary": make_runs_summary(), + } + base.update(overrides) + return TeamRunsAnalysis(**base) + + +def make_runs_context(**overrides: object) -> LeagueRunsContext: + base: dict[str, object] = { + "season": 2025, + "teams_represented": 2, + "team_game_records": 10, + "total_runs": 45, + "runs_per_game": 4.5, + } + base.update(overrides) + return LeagueRunsContext(**base) + + +def test_valid_runs_analysis_is_accepted() -> None: + assert make_runs_analysis().summary.season_average == 4.0 + + +def test_a_shutout_is_valid_on_a_runs_point() -> None: + assert make_runs_point(runs=0).runs == 0 + + +def test_negative_runs_are_rejected_on_a_point() -> None: + with pytest.raises(ValidationError): + make_runs_point(runs=-1) + + +def test_runs_point_rejects_unknown_fields() -> None: + with pytest.raises(ValidationError): + make_runs_point(hits=8) + + +def test_runs_point_is_immutable() -> None: + point = make_runs_point() + with pytest.raises(ValidationError): + point.runs = 9 # type: ignore[misc] + + +def test_runs_prior_window_fields_must_agree() -> None: + with pytest.raises(ValidationError, match="must both be"): + make_runs_summary(prior_window_average=3.0) + with pytest.raises(ValidationError, match="must both be"): + make_runs_summary(change_vs_prior_window=1.0) + + +def test_runs_prior_window_fields_may_both_be_present() -> None: + summary = make_runs_summary(prior_window_average=3.0, change_vs_prior_window=1.0) + assert summary.change_vs_prior_window == 1.0 + + +def test_runs_change_may_be_negative() -> None: + """Scoring less than the previous window is a real result, not invalid.""" + summary = make_runs_summary(prior_window_average=6.0, change_vs_prior_window=-2.0) + assert summary.change_vs_prior_window == -2.0 + + +def test_runs_summary_must_match_the_number_of_points() -> None: + with pytest.raises(ValidationError, match="games_played"): + make_runs_analysis(summary=make_runs_summary(games_played=2)) + + +def test_runs_analysis_requires_at_least_one_point() -> None: + with pytest.raises(ValidationError): + make_runs_analysis(points=()) + + +def test_runs_analysis_rejects_unknown_fields() -> None: + with pytest.raises(ValidationError): + make_runs_analysis(league_average=4.4) + + +def test_runs_analysis_last_game_date_is_the_final_point() -> None: + analysis = make_runs_analysis( + points=( + make_runs_point(), + make_runs_point( + game_pk=776001, + season_game_number=2, + game_date=date(2025, 3, 28), + ), + ), + summary=make_runs_summary(games_played=2), + ) + assert analysis.last_game_date == date(2025, 3, 28) + + +def test_runs_context_accepts_consistent_totals() -> None: + assert make_runs_context().runs_per_game == 4.5 + + +def test_runs_context_rejects_an_average_that_disagrees_with_its_totals() -> None: + with pytest.raises(ValidationError, match="runs_per_game"): + make_runs_context(runs_per_game=9.9) + + +def test_runs_context_rejects_more_teams_than_records() -> None: + with pytest.raises(ValidationError, match="teams_represented"): + make_runs_context(teams_represented=30) + + +def test_runs_context_requires_at_least_one_record() -> None: + with pytest.raises(ValidationError): + make_runs_context(team_game_records=0, total_runs=0, runs_per_game=0.0) + + +def test_runs_context_is_immutable_and_closed() -> None: + context = make_runs_context() + with pytest.raises(ValidationError): + context.runs_per_game = 1.0 # type: ignore[misc] + with pytest.raises(ValidationError): + make_runs_context(mlb_games=2430) + + +def test_runs_comparison_rejects_a_difference_that_does_not_subtract() -> None: + """The page cannot be handed a difference nothing in it produced.""" + with pytest.raises(ValidationError, match="difference_vs_mlb"): + TeamRunsLeagueComparison( + team_id=136, + team_name="Seattle Mariners", + season=2025, + team_runs_per_game=5.0, + league=make_runs_context(), + difference_vs_mlb=2.0, + ) + + +def test_runs_comparison_rejects_a_league_context_from_another_season() -> None: + with pytest.raises(ValidationError, match="season"): + TeamRunsLeagueComparison( + team_id=136, + team_name="Seattle Mariners", + season=2026, + team_runs_per_game=5.0, + league=make_runs_context(season=2025), + difference_vs_mlb=0.5, + ) + + +def test_runs_comparison_allows_a_negative_difference() -> None: + comparison = TeamRunsLeagueComparison( + team_id=136, + team_name="Seattle Mariners", + season=2025, + team_runs_per_game=4.0, + league=make_runs_context(), + difference_vs_mlb=-0.5, + ) + assert comparison.difference_vs_mlb == -0.5 diff --git a/tests/test_analytics_team_runs.py b/tests/test_analytics_team_runs.py new file mode 100644 index 0000000..c35d58a --- /dev/null +++ b/tests/test_analytics_team_runs.py @@ -0,0 +1,237 @@ +"""Tests for team run-scoring analytics. + +Every case here is offline and built from normalized batting lines, never from +the database or the MLB API. +""" + +from datetime import date + +import pytest + +from app.analytics.team_runs import ( + DEFAULT_ROLLING_WINDOW, + TeamRunsAnalysisError, + build_team_runs_analysis, +) +from tests.factories import ( + MARINERS_ID, + MARINERS_NAME, + TWINS_ID, + TWINS_NAME, + make_batting_line, + make_season, +) + + +def season_of(runs: list[int], **kwargs: object): + """Build a stored team-season carrying the given per-game run totals.""" + return make_season(hits=[8] * len(runs), runs=runs, **kwargs) + + +def analysis_of(runs: list[int], *, window: int = 3, **kwargs: object): + return build_team_runs_analysis(season_of(runs, **kwargs), rolling_window=window) + + +# ------------------------------------------------------------- the happy path + + +def test_one_game_is_its_own_season() -> None: + analysis = analysis_of([5]) + assert analysis.summary.games_played == 1 + assert analysis.summary.season_average == pytest.approx(5.0) + assert analysis.summary.recent_average == pytest.approx(5.0) + assert analysis.points[0].rolling_average == pytest.approx(5.0) + + +def test_multiple_games_become_one_point_each() -> None: + analysis = analysis_of([5, 3, 7, 1]) + assert len(analysis.points) == 4 + assert [point.runs for point in analysis.points] == [5, 3, 7, 1] + + +def test_the_analysis_carries_the_team_and_season() -> None: + analysis = analysis_of([4, 4]) + assert (analysis.team_id, analysis.team_name) == (MARINERS_ID, MARINERS_NAME) + assert analysis.season == 2025 + + +def test_season_game_numbers_are_a_continuous_index() -> None: + analysis = analysis_of([2, 2, 2, 2, 2]) + assert [point.season_game_number for point in analysis.points] == [1, 2, 3, 4, 5] + + +def test_points_carry_the_opponent_and_home_away() -> None: + analysis = analysis_of([4, 4]) + assert analysis.points[0].opponent_name == TWINS_NAME + assert analysis.points[0].home_away == "home" + assert analysis.points[1].home_away == "away" + + +def test_a_shutout_is_a_real_zero() -> None: + """Nobody scored is a genuine 0, and it counts as a completed game.""" + analysis = analysis_of([0, 6]) + assert analysis.summary.games_played == 2 + assert analysis.summary.season_average == pytest.approx(3.0) + + +def test_the_last_game_date_is_exposed_for_the_footer() -> None: + analysis = analysis_of([4, 4, 4]) + assert analysis.last_game_date == date(2025, 3, 29) + + +# ------------------------------------------------------------------- ordering + + +def test_games_are_ordered_by_date_regardless_of_input_order() -> None: + games = season_of([1, 2, 3]) + analysis = build_team_runs_analysis(list(reversed(games)), rolling_window=3) + assert [point.runs for point in analysis.points] == [1, 2, 3] + + +def test_a_doubleheader_keeps_its_real_sequence() -> None: + """Same date, so game_number decides which half came first.""" + day = date(2025, 5, 18) + games = [ + make_batting_line(game_pk=900002, game_date=day, game_number=2, runs=9), + make_batting_line(game_pk=900001, game_date=day, game_number=1, runs=2), + ] + analysis = build_team_runs_analysis(games, rolling_window=2) + assert [point.runs for point in analysis.points] == [2, 9] + assert [point.game_number for point in analysis.points] == [1, 2] + + +def test_game_pk_breaks_a_tie_on_date_and_game_number() -> None: + day = date(2025, 5, 18) + games = [ + make_batting_line(game_pk=900_020, game_date=day, runs=6), + make_batting_line(game_pk=900_010, game_date=day, runs=1), + ] + analysis = build_team_runs_analysis(games, rolling_window=2) + assert [point.game_pk for point in analysis.points] == [900_010, 900_020] + + +# ------------------------------------------------------------ rolling average + + +def test_the_rolling_average_is_the_trailing_mean() -> None: + analysis = analysis_of([3, 6, 9, 0], window=2) + assert [point.rolling_average for point in analysis.points] == pytest.approx( + [3.0, 4.5, 7.5, 4.5] + ) + + +def test_early_points_use_every_game_played_so_far() -> None: + """No gap at the start of a season: game 1 is its own average.""" + analysis = analysis_of([4, 8, 6], window=15) + assert [point.rolling_average for point in analysis.points] == pytest.approx( + [4.0, 6.0, 6.0] + ) + + +def test_a_full_window_stops_growing() -> None: + analysis = analysis_of([10, 0, 0, 0, 0], window=2) + assert analysis.points[-1].rolling_average == pytest.approx(0.0) + + +def test_the_selected_window_is_carried_on_the_analysis() -> None: + assert analysis_of([4] * 10, window=5).rolling_window == 5 + + +def test_the_default_window_is_fifteen_games() -> None: + analysis = build_team_runs_analysis(season_of([4] * 20)) + assert analysis.rolling_window == DEFAULT_ROLLING_WINDOW == 15 + + +# ------------------------------------------------------------------- summary + + +def test_the_season_average_is_total_runs_over_games_played() -> None: + analysis = analysis_of([5, 3, 7, 1]) + assert analysis.summary.season_average == pytest.approx(16 / 4) + + +def test_the_season_average_ignores_the_rolling_window() -> None: + """The window smooths the chart; it does not narrow the season average.""" + wide = analysis_of([2] * 10 + [8] * 10, window=30) + narrow = analysis_of([2] * 10 + [8] * 10, window=5) + assert wide.summary.season_average == narrow.summary.season_average + + +def test_the_recent_average_covers_the_last_window_of_games() -> None: + analysis = analysis_of([1] * 10 + [7] * 5, window=5) + assert analysis.summary.recent_average == pytest.approx(7.0) + + +def test_the_recent_average_uses_every_game_when_the_window_is_longer() -> None: + analysis = analysis_of([2, 4], window=30) + assert analysis.summary.recent_average == pytest.approx(3.0) + + +def test_the_recent_average_matches_the_last_rolling_point() -> None: + analysis = analysis_of([3, 9, 1, 5, 8], window=3) + assert analysis.summary.recent_average == pytest.approx( + analysis.points[-1].rolling_average + ) + + +def test_games_played_equals_the_number_of_points() -> None: + analysis = analysis_of([4] * 7) + assert analysis.summary.games_played == len(analysis.points) == 7 + + +def test_the_prior_window_needs_two_complete_windows() -> None: + """A partial prior window would report a change caused by sample size.""" + assert analysis_of([4] * 9, window=5).summary.prior_window_average is None + assert analysis_of([4] * 10, window=5).summary.prior_window_average is not None + + +def test_the_prior_window_change_is_the_difference_between_the_windows() -> None: + analysis = analysis_of([2] * 5 + [6] * 5, window=5) + assert analysis.summary.prior_window_average == pytest.approx(2.0) + assert analysis.summary.change_vs_prior_window == pytest.approx(4.0) + + +# ------------------------------------------------------------ rejected inputs + + +def test_empty_input_is_rejected() -> None: + with pytest.raises(TeamRunsAnalysisError, match="no completed games"): + build_team_runs_analysis([]) + + +def test_mixed_teams_are_rejected() -> None: + games = [ + *season_of([4], team_id=MARINERS_ID, team_name=MARINERS_NAME), + *season_of([5], team_id=TWINS_ID, team_name=TWINS_NAME), + ] + with pytest.raises(TeamRunsAnalysisError, match="one team and one season"): + build_team_runs_analysis(games) + + +def test_mixed_seasons_are_rejected() -> None: + games = [*season_of([4], season=2025), *season_of([5], season=2026)] + with pytest.raises(TeamRunsAnalysisError, match="one team and one season"): + build_team_runs_analysis(games) + + +@pytest.mark.parametrize("window", [0, -1, -30]) +def test_a_non_positive_rolling_window_is_rejected(window: int) -> None: + with pytest.raises(TeamRunsAnalysisError, match="at least 1 game"): + build_team_runs_analysis(season_of([4, 4]), rolling_window=window) + + +def test_the_window_is_validated_before_the_games_are_read() -> None: + """An invalid window is the caller's bug either way; say so plainly.""" + with pytest.raises(TeamRunsAnalysisError, match="at least 1 game"): + build_team_runs_analysis([], rolling_window=0) + + +# ----------------------------------------------------------- runs, not hits + + +def test_the_analysis_reads_runs_and_not_hits() -> None: + """The two columns are different statistics on the same stored row.""" + games = make_season(hits=[12, 12, 12], runs=[1, 2, 3]) + analysis = build_team_runs_analysis(games, rolling_window=3) + assert [point.runs for point in analysis.points] == [1, 2, 3] + assert analysis.summary.season_average == pytest.approx(2.0) diff --git a/tests/test_charts_runs.py b/tests/test_charts_runs.py new file mode 100644 index 0000000..a959248 --- /dev/null +++ b/tests/test_charts_runs.py @@ -0,0 +1,230 @@ +"""Tests for the runs figure contract, not for Plotly itself.""" + +import pytest + +from app.analytics.league_runs import compare_team_runs_to_league +from app.analytics.team_runs import build_team_runs_analysis +from app.web.charts import ( + MLB_AVERAGE_TRACE_NAME, + RAW_RUNS_TRACE_NAME, + RUNS_CHART_DIV_ID, + RUNS_Y_AXIS_TITLE, + TEAM_SEASON_AVERAGE_TRACE_NAME, + X_AXIS_TITLE, + build_team_runs_figure, + render_figure_html, + rolling_average_trace_name, +) +from tests.factories import make_league_runs_context, make_season + +VALUES = [5, 1, 9, 3, 7] + + +def analysis_for(runs: list[int], window: int = 5): + return build_team_runs_analysis( + make_season(hits=[8] * len(runs), runs=runs), + rolling_window=window, + ) + + +@pytest.fixture +def figure(): + return build_team_runs_figure(analysis_for(VALUES)) + + +@pytest.fixture +def league_figure(): + """A figure built with MLB context, as a COMPLETE season gets.""" + analysis = analysis_for(VALUES) + league = make_league_runs_context(total_runs=42, team_game_records=10) + return build_team_runs_figure( + analysis, compare_team_runs_to_league(analysis, league) + ) + + +def test_figure_has_three_traces_without_mlb_context(figure) -> None: + """No complete league coverage means no MLB line, and a working chart.""" + assert len(figure.data) == 3 + + +def test_trace_names_describe_the_three_series(figure) -> None: + assert [trace.name for trace in figure.data] == [ + RAW_RUNS_TRACE_NAME, + "5-Game Average", + TEAM_SEASON_AVERAGE_TRACE_NAME, + ] + + +def test_mlb_context_adds_a_fourth_named_trace(league_figure) -> None: + assert [trace.name for trace in league_figure.data] == [ + RAW_RUNS_TRACE_NAME, + "5-Game Average", + TEAM_SEASON_AVERAGE_TRACE_NAME, + MLB_AVERAGE_TRACE_NAME, + ] + + +def test_no_mlb_trace_without_a_comparison(figure) -> None: + assert MLB_AVERAGE_TRACE_NAME not in [trace.name for trace in figure.data] + + +def test_the_mlb_trace_plots_the_league_average(league_figure) -> None: + assert list(league_figure.data[3].y) == pytest.approx([4.2, 4.2]) + + +def test_the_mlb_trace_spans_the_whole_season(league_figure) -> None: + assert list(league_figure.data[3].x) == [1, 5] + + +def test_the_mlb_trace_is_dotted_so_the_two_reference_lines_differ( + league_figure, +) -> None: + assert league_figure.data[3].line.dash == "dot" + assert league_figure.data[2].line.dash == "dash" + assert league_figure.data[3].line.color != league_figure.data[2].line.color + + +def test_the_mlb_trace_has_no_hover(league_figure) -> None: + assert league_figure.data[3].hoverinfo == "skip" + + +def test_mlb_context_does_not_change_the_existing_series(league_figure) -> None: + """The rolling average and the game values mean exactly what they did.""" + assert list(league_figure.data[0].y) == VALUES + assert list(league_figure.data[1].y) == pytest.approx([5.0, 3.0, 5.0, 4.5, 5.0]) + assert list(league_figure.data[2].y) == pytest.approx([5.0, 5.0]) + + +def test_raw_trace_is_labelled_as_game_runs(figure) -> None: + assert figure.data[0].name == "Game Runs" + + +def test_raw_trace_plots_the_game_runs(figure) -> None: + assert list(figure.data[0].y) == VALUES + + +def test_raw_trace_uses_the_season_game_number_for_x(figure) -> None: + assert list(figure.data[0].x) == [1, 2, 3, 4, 5] + + +@pytest.mark.parametrize("window", [5, 10, 15, 30]) +def test_rolling_trace_label_reflects_the_selected_window(window: int) -> None: + figure = build_team_runs_figure(analysis_for([4] * 40, window)) + assert figure.data[1].name == f"{window}-Game Average" + assert rolling_average_trace_name(window) == f"{window}-Game Average" + + +def test_rolling_trace_plots_the_trailing_average(figure) -> None: + assert list(figure.data[1].y) == pytest.approx([5.0, 3.0, 5.0, 4.5, 5.0]) + + +def test_rolling_trace_joins_points_with_straight_segments(figure) -> None: + """Splines would draw averages between games that were never calculated.""" + assert figure.data[1].line.shape in (None, "linear") + + +def test_no_trace_uses_spline_smoothing(figure) -> None: + assert all(trace.line.shape in (None, "linear") for trace in figure.data) + + +def test_no_trace_uses_spline_smoothing_with_mlb_context(league_figure) -> None: + assert all(trace.line.shape in (None, "linear") for trace in league_figure.data) + + +def test_the_team_line_says_whose_average_it_is(figure) -> None: + """Two reference lines can share the chart, so "Season Average" is ambiguous.""" + assert figure.data[2].name == "Team Season Average" + + +def test_season_average_trace_is_the_stored_season_average(figure) -> None: + expected = sum(VALUES) / len(VALUES) + assert list(figure.data[2].y) == pytest.approx([expected, expected]) + + +def test_season_average_trace_spans_the_whole_season(figure) -> None: + assert list(figure.data[2].x) == [1, 5] + + +def test_season_average_trace_is_dashed(figure) -> None: + assert figure.data[2].line.dash == "dash" + + +def test_season_average_trace_reads_the_authoritative_summary_value() -> None: + analysis = analysis_for(VALUES) + figure = build_team_runs_figure(analysis) + assert figure.data[2].y[0] == pytest.approx(analysis.summary.season_average) + + +def test_season_average_trace_has_no_hover(figure) -> None: + assert figure.data[2].hoverinfo == "skip" + + +def test_the_season_average_line_is_labelled_with_its_value(figure) -> None: + annotation = figure.layout.annotations[0] + assert TEAM_SEASON_AVERAGE_TRACE_NAME in annotation.text + assert "5.00" in annotation.text + assert MLB_AVERAGE_TRACE_NAME not in annotation.text + + +def test_only_the_mlb_line_is_labelled_when_it_is_drawn(league_figure) -> None: + """Both lines can sit a tenth of a run apart, where labels collide.""" + annotations = league_figure.layout.annotations + assert len(annotations) == 1 + assert MLB_AVERAGE_TRACE_NAME in annotations[0].text + assert "4.20" in annotations[0].text + + +def test_axis_titles_name_runs_scored(figure) -> None: + assert figure.layout.xaxis.title.text == X_AXIS_TITLE + assert figure.layout.yaxis.title.text == RUNS_Y_AXIS_TITLE + + +def test_y_axis_says_scored_so_runs_allowed_cannot_be_assumed(figure) -> None: + assert figure.layout.yaxis.title.text == "Runs Scored per Game" + + +def test_y_axis_has_no_hardcoded_maximum(figure) -> None: + assert figure.layout.yaxis.range is None + + +def test_y_axis_grows_with_a_blowout(figure) -> None: + grown = build_team_runs_figure(analysis_for([1, 1, 21])) + assert grown.layout.yaxis.range is None + + +def test_y_axis_starts_at_zero(figure) -> None: + assert figure.layout.yaxis.rangemode == "tozero" + + +def test_x_axis_ticks_carry_the_game_date(figure) -> None: + """A game number alone does not say when in the season a stretch happened.""" + assert figure.layout.xaxis.ticktext[0] == "1
Mar 27" + + +def test_hover_shows_date_matchup_runs_and_average(figure) -> None: + template = figure.data[0].hovertemplate + assert "Runs: %{customdata[2]}" in template + assert "5-Game Avg: %{customdata[3]:.2f}" in template + + +def test_hover_data_carries_formatted_date_and_matchup(figure) -> None: + first = figure.data[0].customdata[0] + assert first[0] == "March 27, 2025" + assert first[1].startswith("vs ") + assert first[2] == VALUES[0] + + +def test_hover_matchup_uses_at_for_away_games(figure) -> None: + assert figure.data[0].customdata[1][1].startswith("at ") + + +def test_a_shutout_is_plotted_as_a_real_zero() -> None: + figure = build_team_runs_figure(analysis_for([0, 4, 8])) + assert list(figure.data[0].y) == [0, 4, 8] + + +def test_rendered_html_uses_the_runs_div_id(figure) -> None: + html = render_figure_html(figure, div_id=RUNS_CHART_DIV_ID) + assert RUNS_CHART_DIV_ID in html + assert "team-hits-chart" not in html + assert "team-strikeouts-chart" not in html diff --git a/tests/test_formatting.py b/tests/test_formatting.py index b7db771..be90fc3 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -5,15 +5,20 @@ import pytest from app.analytics.league_hitting import compare_team_hits_to_league +from app.analytics.league_runs import compare_team_runs_to_league from app.analytics.league_strikeouts import compare_team_strikeouts_to_league from app.analytics.team_hitting import build_team_hits_analysis +from app.analytics.team_runs import build_team_runs_analysis from app.analytics.team_strikeouts import build_team_strikeouts_analysis from app.web.formatting import ( LEAGUE_COMPARISON_UNAVAILABLE_NOTE, + LEAGUE_RUNS_UNAVAILABLE_NOTE, LEAGUE_STRIKEOUTS_UNAVAILABLE_NOTE, + build_runs_summary_cards, build_strikeout_summary_cards, build_summary_cards, format_league_comparison_note, + format_league_runs_note, format_league_strikeouts_backfill_note, format_league_strikeouts_note, format_long_date, @@ -22,6 +27,7 @@ ) from tests.factories import ( make_league_hits_context, + make_league_runs_context, make_league_strikeouts_context, make_season, ) @@ -262,3 +268,104 @@ def test_strikeout_games_played_counts_completed_games() -> None: def test_short_date_drops_the_year_for_axis_ticks() -> None: assert format_short_date(date(2025, 5, 8)) == "May 8" assert format_short_date(date(2025, 9, 28)) == "Sep 28" + + +def runs_comparison(runs: list[int], *, window: int, mlb_runs_per_game: float): + """Build a team runs analysis and an MLB comparison against a chosen average.""" + analysis = build_team_runs_analysis( + make_season(hits=[8] * len(runs), runs=runs), rolling_window=window + ) + league = make_league_runs_context( + total_runs=round(mlb_runs_per_game * 100), team_game_records=100 + ) + return analysis, compare_team_runs_to_league(analysis, league) + + +def test_runs_cards_use_the_same_four_labels_as_the_other_pages() -> None: + analysis, comparison = runs_comparison([4] * 40, window=10, mlb_runs_per_game=4.42) + cards = build_runs_summary_cards(analysis, comparison) + assert [card.label for card in cards] == [ + "Recent 10-Game Avg", + "Season Avg", + "vs MLB", + "Games Played", + ] + + +def test_runs_cards_are_captioned_as_runs_scored() -> None: + analysis, comparison = runs_comparison([4] * 20, window=5, mlb_runs_per_game=4.0) + cards = build_runs_summary_cards(analysis, comparison) + assert cards[0].caption == "Runs Scored per Game" + assert cards[1].caption == "Runs Scored per Game" + assert cards[2].caption == "Runs Scored per Game" + assert cards[3].caption == "Completed Games" + + +def test_runs_cards_round_for_display_only() -> None: + analysis = build_team_runs_analysis( + make_season(hits=[8] * 10, runs=[3] * 5 + [6] * 5), rolling_window=5 + ) + cards = build_runs_summary_cards(analysis) + assert cards[0].value == "6.00" + assert cards[1].value == "4.50" + assert cards[3].value == "10" + + +def test_scoring_more_than_mlb_reads_as_a_positive_number() -> None: + """The worked example from issue #24: 4.75 against 4.42 reads +0.33.""" + analysis, comparison = runs_comparison( + [5, 5, 5, 4], window=4, mlb_runs_per_game=4.42 + ) + card = build_runs_summary_cards(analysis, comparison)[2] + assert card.value == "+0.33" + + +def test_scoring_less_than_mlb_reads_as_a_negative_number() -> None: + analysis, comparison = runs_comparison([4] * 4, window=4, mlb_runs_per_game=4.42) + assert build_runs_summary_cards(analysis, comparison)[2].value == "-0.42" + + +def test_matching_mlb_exactly_reads_as_a_signed_zero() -> None: + """+0.00 is a real result and must stay distinct from unavailable.""" + analysis, comparison = runs_comparison([4] * 4, window=4, mlb_runs_per_game=4.0) + assert build_runs_summary_cards(analysis, comparison)[2].value == "+0.00" + + +def test_runs_league_card_says_when_no_mlb_average_is_available() -> None: + """Without complete league coverage the card must not invent a number.""" + analysis = build_team_runs_analysis( + make_season(hits=[8] * 9, runs=[4] * 9), rolling_window=5 + ) + card = build_runs_summary_cards(analysis)[2] + assert card.value == "—" + assert card.caption == "Comparison unavailable" + assert card.value != "0.00" + + +def test_runs_league_note_explains_why_a_comparison_is_missing() -> None: + note = format_league_runs_note(None) + assert note == LEAGUE_RUNS_UNAVAILABLE_NOTE + assert "complete league-season import" in note + + +def test_runs_league_note_reports_the_average_and_what_it_covers() -> None: + _, available = runs_comparison([5] * 10, window=5, mlb_runs_per_game=4.42) + note = format_league_runs_note(available) + assert "scored 4.42 runs per game" in note + assert "100 team-game records" in note + assert "currently stored" in note + assert "total runs divided by total team-game records" in note + + +def test_runs_league_note_does_not_call_the_season_finished() -> None: + _, available = runs_comparison([5] * 10, window=5, mlb_runs_per_game=4.42) + note = format_league_runs_note(available) + assert "finished being played" in note + assert "season complete" not in note.lower() + + +def test_runs_games_played_counts_completed_games() -> None: + analysis = build_team_runs_analysis( + make_season(hits=[8] * 12, runs=[4] * 12), rolling_window=5 + ) + assert build_runs_summary_cards(analysis)[3].value == "12" diff --git a/tests/test_navigation.py b/tests/test_navigation.py index fd6262d..c60e140 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -1,42 +1,56 @@ -"""Tests for navigation between the metric pages.""" +"""Tests for navigation between the metric pages. + +Issue #24 added a third entry. The list is asserted in full rather than by +membership, so a page added without a route, or a route added without a link, +fails here. +""" from app.web.navigation import ( HITS_PATH, + RUNS_PATH, STRIKEOUTS_PATH, build_nav_links, ) -def test_both_metric_pages_are_linked() -> None: +def test_every_metric_page_is_linked() -> None: links = build_nav_links(current_path=HITS_PATH) - assert [link.label for link in links] == ["Hits", "Batting Strikeouts"] + assert [link.label for link in links] == ["Hits", "Batting Strikeouts", "Runs"] def test_links_point_at_real_routes() -> None: links = build_nav_links(current_path=HITS_PATH) - assert [link.href for link in links] == ["/", "/strikeouts"] + assert [link.href for link in links] == ["/", "/strikeouts", "/runs"] def test_the_current_page_is_marked() -> None: links = build_nav_links(current_path=STRIKEOUTS_PATH) - assert [link.is_current for link in links] == [False, True] + assert [link.is_current for link in links] == [False, True, False] + + +def test_the_runs_page_can_be_the_current_one() -> None: + links = build_nav_links(current_path=RUNS_PATH) + assert [link.is_current for link in links] == [False, False, True] def test_only_one_page_is_current_at_a_time() -> None: - links = build_nav_links(current_path=HITS_PATH) - assert sum(link.is_current for link in links) == 1 + for path in (HITS_PATH, STRIKEOUTS_PATH, RUNS_PATH): + links = build_nav_links(current_path=path) + assert sum(link.is_current for link in links) == 1 def test_selection_is_carried_between_pages() -> None: links = build_nav_links(current_path=HITS_PATH, team_id=136, season=2025, window=15) assert links[1].href == "/strikeouts?team_id=136&season=2025&window=15" + assert links[2].href == "/runs?team_id=136&season=2025&window=15" def test_no_selection_produces_plain_paths() -> None: links = build_nav_links(current_path=HITS_PATH) - assert [link.href for link in links] == ["/", "/strikeouts"] + assert [link.href for link in links] == ["/", "/strikeouts", "/runs"] def test_unset_values_are_left_out_of_the_query() -> None: links = build_nav_links(current_path=HITS_PATH, team_id=136, window=30) assert links[1].href == "/strikeouts?team_id=136&window=30" + assert links[2].href == "/runs?team_id=136&window=30" diff --git a/tests/test_web_runs.py b/tests/test_web_runs.py new file mode 100644 index 0000000..6982e7f --- /dev/null +++ b/tests/test_web_runs.py @@ -0,0 +1,491 @@ +"""Tests for the /runs page and its place in the metric navigation. + +Shares the database-backed client fixtures with ``test_web`` so every metric +page is exercised against the same persisted rows. Everything here is offline. +""" + +import re +from collections.abc import Callable, Generator, Iterator +from pathlib import Path + +import pytest +import requests +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.database.engine import build_engine, build_session_factory +from app.database.repositories import upsert_team_season +from app.main import create_app +from app.web.dependencies import get_db_session +from tests.factories import make_season + +SeedFn = Callable[..., None] + +MARINERS = 136 +SEASON = 2025 + + +@pytest.fixture +def session_factory(migrated_db_path: Path) -> Generator[Callable[[], Session]]: + engine = build_engine(f"sqlite:///{migrated_db_path}") + factory = build_session_factory(engine) + try: + yield factory + finally: + engine.dispose() + + +@pytest.fixture +def seed(session_factory: Callable[[], Session]) -> SeedFn: + def _seed(**kwargs: object) -> None: + session = session_factory() + try: + upsert_team_season(session, lines=make_season(**kwargs)) + session.commit() + finally: + session.close() + + return _seed + + +@pytest.fixture +def client(session_factory: Callable[[], Session]) -> TestClient: + app = create_app() + + def override_session() -> Iterator[Session]: + session = session_factory() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db_session] = override_session + return TestClient(app) + + +def seed_with_runs(seed: SeedFn, values: list[int], **kwargs: object) -> None: + seed(hits=[8] * len(values), runs=values, **kwargs) + + +def prose(body: str) -> str: + """Collapse whitespace so a sentence wrapped in the template still matches.""" + return re.sub(r"\s+", " ", body) + + +# --- the page renders persisted run data -------------------------------------- + + +def test_runs_page_renders_with_data(client: TestClient, seed: SeedFn) -> None: + seed_with_runs(seed, [5, 3, 7]) + response = client.get("/runs") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + +def test_runs_page_uses_the_precise_title(client: TestClient, seed: SeedFn) -> None: + seed_with_runs(seed, [5, 3, 7]) + assert "Team Run Scoring Trends" in client.get("/runs").text + + +def test_runs_page_shows_the_subtitle(client: TestClient, seed: SeedFn) -> None: + seed_with_runs(seed, [5, 3, 7]) + body = prose(client.get("/runs").text) + assert "runs a team is scoring per game" in body + + +def test_chart_heading_names_the_team_and_runs( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [5, 3, 7]) + assert "Seattle Mariners — Runs Scored per Game" in client.get("/runs").text + + +def test_chart_heading_uses_the_stored_team_name( + client: TestClient, seed: SeedFn +) -> None: + """Nothing about Seattle is hardcoded into the chart title.""" + seed_with_runs(seed, [4, 4], team_id=112, team_name="Chicago Cubs") + body = client.get("/runs?team_id=112&season=2025").text + assert "Chicago Cubs — Runs Scored per Game" in body + + +def test_runs_chart_div_is_rendered(client: TestClient, seed: SeedFn) -> None: + seed_with_runs(seed, [5, 3, 7]) + assert "team-runs-chart" in client.get("/runs").text + + +def test_the_page_says_runs_scored_not_runs_allowed( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [5, 3, 7]) + body = prose(client.get("/runs").text) + assert "Runs Scored per Game" in body + assert "runs scored, not runs allowed" in body + assert "run differential" in body + + +def test_the_page_charts_runs_and_not_hits(client: TestClient, seed: SeedFn) -> None: + """The two columns live on the same stored row; the page must read runs.""" + seed(hits=[12, 12, 12], runs=[1, 2, 3]) + body = client.get("/runs?window=5").text + assert "Seattle Mariners — Runs Scored per Game" in body + # Season average is 2.00 runs, never 12.00 hits. + assert "2.00" in body + assert "12.00" not in body + + +def test_rows_with_unknown_strikeouts_still_chart_runs( + client: TestClient, seed: SeedFn +) -> None: + """Runs are required on every stored row, so legacy rows are fine here.""" + seed(hits=[8, 9, 10], runs=[4, 2, 6]) + response = client.get("/runs") + assert response.status_code == 200 + assert "team-runs-chart" in response.text + # The same rows still send the strikeouts page to its backfill state. + assert client.get("/strikeouts").status_code == 409 + + +# --- selection ---------------------------------------------------------------- + + +def test_selected_team_and_season_are_honoured( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [2, 2], team_id=112, team_name="Chicago Cubs") + seed_with_runs(seed, [5] * 5) + body = client.get(f"/runs?team_id={MARINERS}&season={SEASON}").text + assert "Seattle Mariners — Runs Scored per Game" in body + assert "2025 regular season" in body + + +def test_the_season_selector_switches_stored_seasons( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [5] * 5, season=2025) + seed_with_runs(seed, [3] * 5, season=2026) + assert "2026 regular season" in client.get("/runs?team_id=136&season=2026").text + assert "2025 regular season" in client.get("/runs?team_id=136&season=2025").text + + +def test_selected_window_changes_the_rolling_label( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [4] * 20) + body = client.get("/runs?team_id=136&season=2025&window=5").text + assert "5-Game Average" in body + + +@pytest.mark.parametrize("window", [5, 10, 15, 30]) +def test_every_supported_window_is_accepted( + client: TestClient, seed: SeedFn, window: int +) -> None: + seed_with_runs(seed, [4] * 40) + response = client.get(f"/runs?window={window}") + assert response.status_code == 200 + assert f"{window}-Game Average" in response.text + + +def test_default_rolling_window_is_fifteen(client: TestClient, seed: SeedFn) -> None: + seed_with_runs(seed, [4] * 20) + assert "15-Game Average" in client.get("/runs").text + + +def test_query_parameters_survive_in_the_form_selection( + client: TestClient, seed: SeedFn +) -> None: + """A shared /runs URL comes back with the same selection applied.""" + seed_with_runs(seed, [4] * 20, team_id=112, team_name="Chicago Cubs") + body = client.get("/runs?team_id=112&season=2025&window=30").text + assert '' in body + assert '' in body + assert '' in body + + +def test_the_selector_form_posts_back_to_runs(client: TestClient, seed: SeedFn) -> None: + seed_with_runs(seed, [4, 4]) + assert 'action="/runs"' in client.get("/runs").text + + +# --- empty, missing, and invalid states --------------------------------------- + + +def test_empty_database_explains_how_to_import(client: TestClient) -> None: + response = client.get("/runs") + assert response.status_code == 200 + assert "No team data has been imported yet" in response.text + assert "scripts/import_team_season.py" in response.text + assert "team-runs-chart" not in response.text + + +def test_unknown_team_follows_the_existing_not_found_contract( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [4, 4]) + response = client.get("/runs?team_id=999") + assert response.status_code == 404 + assert "No games are stored for team id 999" in response.text + + +def test_unknown_season_follows_the_existing_not_found_contract( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [4, 4]) + response = client.get(f"/runs?team_id={MARINERS}&season=1999") + assert response.status_code == 404 + assert "No 1999 games are stored" in response.text + + +def test_not_found_state_still_offers_the_selectors( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [4, 4]) + body = client.get("/runs?team_id=999").text + assert 'id="team_id"' in body + assert 'action="/runs"' in body + + +def test_invalid_window_is_rejected_readably(client: TestClient, seed: SeedFn) -> None: + seed_with_runs(seed, [4, 4]) + response = client.get( + "/runs?window=7", headers={"accept": "text/html,application/xhtml+xml"} + ) + assert response.status_code == 422 + assert "Traceback" not in response.text + + +def test_a_non_numeric_team_id_is_rejected_readably( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [4, 4]) + response = client.get( + "/runs?team_id=seattle", headers={"accept": "text/html,application/xhtml+xml"} + ) + assert response.status_code == 422 + assert "Traceback" not in response.text + + +def test_missing_schema_points_at_the_migration_command(tmp_path: Path) -> None: + engine = build_engine(f"sqlite:///{tmp_path / 'unmigrated.db'}") + factory = build_session_factory(engine) + app = create_app() + + def override_session() -> Iterator[Session]: + session = factory() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db_session] = override_session + try: + response = TestClient(app).get("/runs") + assert response.status_code == 503 + assert "poetry run alembic upgrade head" in response.text + assert "Traceback" not in response.text + finally: + engine.dispose() + + +# --- summary cards and explanation -------------------------------------------- + + +def test_summary_cards_are_rendered(client: TestClient, seed: SeedFn) -> None: + seed_with_runs(seed, [5, 3, 7, 1]) + body = client.get("/runs?window=5").text + for label in ("Recent 5-Game Avg", "Season Avg", "vs MLB", "Games Played"): + assert label in body + + +def test_summary_cards_describe_stored_completed_games( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [5, 3, 7, 1]) + body = prose(client.get("/runs").text) + assert "Completed Games" in body + assert "completed games currently stored" in body + + +def test_games_played_counts_the_stored_games(client: TestClient, seed: SeedFn) -> None: + seed_with_runs(seed, [4] * 17) + body = client.get("/runs").text + assert "Games Played" in body + assert ">17<" in body + + +def test_without_league_coverage_the_mlb_card_is_unavailable( + client: TestClient, seed: SeedFn +) -> None: + """Nothing here records league coverage, so the card must read a dash.""" + seed_with_runs(seed, [5, 3, 7]) + body = client.get("/runs?window=30").text + assert "vs MLB" in body + assert "Comparison unavailable" in body + assert "+0.00" not in body + + +def test_the_explanation_describes_the_rolling_average( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [5, 3, 7]) + body = prose(client.get("/runs?window=5").text) + assert "5-game average covers that game and the 4 games before it" in body + assert "recent scoring trend" in body + assert "early-season points use every game played so far" in body + + +def test_the_explanation_does_not_claim_a_complete_season( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [5, 3, 7]) + body = prose(client.get("/runs").text) + assert "completed games currently stored for this season" in body + + +def test_the_page_uses_the_same_layout_regions_as_the_other_pages( + client: TestClient, seed: SeedFn +) -> None: + """Every metric page is built from the same shell, cards, and panels.""" + seed_with_runs(seed, [5, 3, 7]) + body = client.get("/runs").text + for region in ( + 'class="site-header"', + 'class="shell page"', + 'class="controls card"', + 'class="card chart-card"', + 'class="summary"', + 'class="about"', + 'class="site-footer"', + ): + assert region in body + + +def test_the_footer_reports_the_date_the_data_runs_through( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [5, 3, 7]) + assert "Data through March 29, 2025" in client.get("/runs").text + + +# --- navigation ---------------------------------------------------------------- + + +def test_every_page_links_to_runs(client: TestClient, seed: SeedFn) -> None: + seed_with_runs(seed, [5, 3, 7]) + for path in ("/", "/strikeouts", "/runs"): + body = client.get(path).text + assert 'href="/runs' in body + assert ">Runs" in body + + +def test_the_runs_page_links_back_to_the_other_pages( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [5, 3, 7]) + body = client.get("/runs").text + assert ">Hits" in body + assert "Batting Strikeouts" in body + + +def test_navigation_marks_the_runs_page_as_current( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [5, 3, 7]) + body = client.get("/runs").text + assert 'aria-current="page"' in body + assert body.count('aria-current="page"') == 1 + + +def test_navigation_preserves_the_selection_into_runs( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [4] * 20) + body = client.get("/?team_id=136&season=2025&window=30").text + assert "/runs?team_id=136&season=2025&window=30" in body + + +def test_navigation_preserves_the_selection_out_of_runs( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [4] * 20) + body = client.get("/runs?team_id=136&season=2025&window=30").text + assert 'href="/?team_id=136&season=2025&window=30"' in body + assert 'href="/strikeouts?team_id=136&season=2025&window=30"' in body + + +def test_navigation_links_resolve_to_real_routes( + client: TestClient, seed: SeedFn +) -> None: + seed(hits=[8] * 20, runs=[4] * 20, strikeouts=[9] * 20) + query = "?team_id=136&season=2025&window=30" + for path in ("/", "/strikeouts", "/runs"): + assert client.get(f"{path}{query}").status_code == 200 + + +def test_navigation_is_present_on_the_empty_state(client: TestClient) -> None: + assert 'href="/runs' in client.get("/runs").text + + +def test_navigation_is_present_on_the_not_found_state( + client: TestClient, seed: SeedFn +) -> None: + seed_with_runs(seed, [4, 4]) + assert 'href="/runs' in client.get("/runs?team_id=999").text + + +# --- the other pages are unchanged --------------------------------------------- + + +def test_the_hits_page_is_unchanged(client: TestClient, seed: SeedFn) -> None: + seed(hits=[8, 9, 10], runs=[4, 2, 6], strikeouts=[9, 8, 7]) + response = client.get("/?team_id=136&season=2025&window=15") + assert response.status_code == 200 + assert "Seattle Mariners — Hits per Game" in response.text + assert "Runs Scored per Game" not in response.text + + +def test_the_strikeouts_page_is_unchanged(client: TestClient, seed: SeedFn) -> None: + seed(hits=[8, 9, 10], runs=[4, 2, 6], strikeouts=[9, 8, 7]) + response = client.get("/strikeouts?team_id=136&season=2025&window=15") + assert response.status_code == 200 + assert "Seattle Mariners — Batting Strikeouts per Game" in response.text + assert "Runs Scored per Game" not in response.text + + +def test_health_is_unchanged(client: TestClient) -> None: + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +# --- the page stays database-backed -------------------------------------------- + + +def test_the_runs_page_never_calls_the_mlb_api( + client: TestClient, seed: SeedFn, monkeypatch: pytest.MonkeyPatch +) -> None: + def fail(*args: object, **kwargs: object) -> None: + raise AssertionError("The web layer must not reach the MLB Stats API") + + monkeypatch.setattr(requests.Session, "request", fail) + monkeypatch.setattr("mlbstatsapi.Mlb.__init__", fail) + monkeypatch.setattr("app.services.team_game_logs.get_team_game_batting_lines", fail) + monkeypatch.setattr("app.services.league_teams.discover_mlb_teams", fail) + + seed_with_runs(seed, [4] * 20) + assert client.get("/runs?team_id=136&season=2025&window=15").status_code == 200 + assert client.get("/runs").status_code == 200 + + +def test_the_empty_runs_page_does_not_try_to_import( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The page tells the reader to run the import; it never runs it itself.""" + + def fail(*args: object, **kwargs: object) -> None: + raise AssertionError("The web layer must not reach the MLB Stats API") + + monkeypatch.setattr(requests.Session, "request", fail) + monkeypatch.setattr("app.services.team_game_logs.get_team_game_batting_lines", fail) + + assert client.get("/runs").status_code == 200 diff --git a/tests/test_web_runs_league_comparison.py b/tests/test_web_runs_league_comparison.py new file mode 100644 index 0000000..b513094 --- /dev/null +++ b/tests/test_web_runs_league_comparison.py @@ -0,0 +1,504 @@ +"""Tests for how the runs page gates MLB run-scoring context. + +Every case here is offline. The database is seeded directly and the recorded +coverage state is written directly, because coverage is what the page reads — +never a live MLB call, and never a row count. + +Unlike batting strikeouts there is only one condition to satisfy: complete +league-season coverage. ``runs`` is required on every persisted team-game +record, so a covered season cannot be holding unknown run totals. +""" + +from collections.abc import Callable, Generator, Iterator +from datetime import datetime +from pathlib import Path + +import pytest +import requests +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.database.engine import build_engine, build_session_factory +from app.database.repositories import ( + record_league_season_ingestion_finish, + record_league_season_ingestion_start, + upsert_team_season, +) +from app.main import create_app +from app.web.dependencies import get_db_session +from app.web.formatting import LEAGUE_RUNS_UNAVAILABLE_NOTE +from tests.factories import ( + MARINERS_ID, + MARINERS_NAME, + TWINS_ID, + TWINS_NAME, + make_season, +) + +STARTED = datetime(2026, 3, 1, 12, 0, 0) +FINISHED = datetime(2026, 3, 1, 12, 30, 0) + +SeedFn = Callable[..., None] +CoverageFn = Callable[..., None] + + +@pytest.fixture +def session_factory(migrated_db_path: Path) -> Generator[Callable[[], Session]]: + engine = build_engine(f"sqlite:///{migrated_db_path}") + factory = build_session_factory(engine) + try: + yield factory + finally: + engine.dispose() + + +@pytest.fixture +def seed(session_factory: Callable[[], Session]) -> SeedFn: + """Persist one team-season, with game ids kept unique across teams.""" + + def _seed( + runs: list[int], + *, + team_id: int = MARINERS_ID, + team_name: str = MARINERS_NAME, + season: int = 2025, + ) -> None: + lines = [ + line.model_copy(update={"game_pk": line.game_pk + team_id * 100_000}) + for line in make_season( + hits=[8] * len(runs), + runs=runs, + team_id=team_id, + team_name=team_name, + season=season, + ) + ] + session = session_factory() + try: + upsert_team_season(session, lines=lines) + session.commit() + finally: + session.close() + + return _seed + + +@pytest.fixture +def record_coverage(session_factory: Callable[[], Session]) -> CoverageFn: + """Write a league-season coverage row the way an ingestion run would.""" + + def _record( + *, + season: int = 2025, + teams: int = 30, + failed: int = 0, + finished: bool = True, + ) -> None: + session = session_factory() + try: + with session.begin(): + record_league_season_ingestion_start( + session, + season=season, + expected_team_count=teams, + started_at=STARTED, + ) + if not finished: + return + with session.begin(): + record_league_season_ingestion_finish( + session, + season=season, + expected_team_count=teams, + successful_team_count=teams - failed, + failed_team_count=failed, + started_at=STARTED, + completed_at=FINISHED, + ) + finally: + session.close() + + return _record + + +@pytest.fixture +def client(session_factory: Callable[[], Session]) -> TestClient: + app = create_app() + + def override_session() -> Iterator[Session]: + session = session_factory() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db_session] = override_session + return TestClient(app) + + +def seed_two_teams(seed: SeedFn, *, season: int = 2025) -> None: + """Mariners score 5 a game, Twins 3; MLB across both is 4.00.""" + seed([5] * 20, team_id=MARINERS_ID, team_name=MARINERS_NAME, season=season) + seed([3] * 20, team_id=TWINS_ID, team_name=TWINS_NAME, season=season) + + +def runs_page( + client: TestClient, + *, + team_id: int = MARINERS_ID, + season: int = 2025, + window: int | None = None, +) -> str: + url = f"/runs?team_id={team_id}&season={season}" + if window is not None: + url = f"{url}&window={window}" + response = client.get(url) + assert response.status_code == 200 + return response.text + + +# ------------------------------------------------------------------ COMPLETE + + +def test_complete_coverage_shows_the_mlb_comparison( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_two_teams(seed) + record_coverage(teams=2) + body = runs_page(client) + + assert "vs MLB" in body + assert "+1.00" in body + assert "scored 4.00 runs per game" in body + assert LEAGUE_RUNS_UNAVAILABLE_NOTE not in body + + +def test_complete_coverage_draws_the_mlb_reference_line( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_two_teams(seed) + record_coverage(teams=2) + body = runs_page(client) + + assert "MLB Average" in body + assert "Team Season Average" in body + assert "Game Runs" in body + + +def test_a_team_below_mlb_reads_as_a_negative_difference( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_two_teams(seed) + record_coverage(teams=2) + assert "-1.00" in runs_page(client, team_id=TWINS_ID) + + +def test_the_page_keeps_the_existing_series_alongside_the_mlb_line( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_two_teams(seed) + record_coverage(teams=2) + body = runs_page(client, window=10) + + assert "Game Runs" in body + assert "10-Game Average" in body + assert "Team Season Average" in body + assert "MLB Average" in body + + +def test_the_page_does_not_call_complete_coverage_a_finished_season( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_two_teams(seed) + record_coverage(teams=2) + body = runs_page(client) + assert "season complete" not in body.lower() + assert "currently stored" in body + + +def test_the_page_reads_the_difference_as_descriptive_context( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_two_teams(seed) + record_coverage(teams=2) + body = runs_page(client) + assert "scored more runs per game" in body + assert "not a measure of significance" in body + assert "no claim about why the two numbers differ" in body + + +def test_the_page_makes_no_ranking_or_adjustment_claim( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + """Rankings, percentiles, and park/opponent adjustments are out of scope.""" + seed_two_teams(seed) + record_coverage(teams=2) + body = runs_page(client).lower() + for claim in ( + "rank", + "percentile", + "park-adjusted", + "park factor", + "opponent-adjusted", + "expected runs", + "statistically significant", + ): + assert claim not in body + # The page names run differential only to say it is not what is shown. + assert "nothing here is a run differential" in " ".join(body.split()) + + +def test_unequal_game_counts_are_weighted_on_the_page( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + """Mariners score 5 over 20 games, Twins 2 in 1: MLB is 102/21, not 3.50. + + The unweighted mean of the two club averages would be 3.50 and would show + a +1.50 difference. The game-weighted answer is about 4.86. + """ + seed([5] * 20, team_id=MARINERS_ID, team_name=MARINERS_NAME) + seed([2], team_id=TWINS_ID, team_name=TWINS_NAME) + record_coverage(teams=2) + + body = runs_page(client) + assert "scored 4.86 runs per game" in body + assert "+0.14" in body + assert "+1.50" not in body + + +def test_the_note_names_the_records_and_teams_behind_the_average( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_two_teams(seed) + record_coverage(teams=2) + body = runs_page(client) + assert "40 team-game records" in body + assert "covering 2 teams" in body + assert "total runs divided by total team-game records" in body + + +def test_the_season_average_is_the_same_number_everywhere( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + """Card, reference line, and comparison all read one team average. + + The Mariners average 5.00, MLB 4.00, so the difference must read +1.00 and + the Season Avg card must read 5.00. Any disagreement here means the page + calculated the team average twice. + """ + seed_two_teams(seed) + record_coverage(teams=2) + body = runs_page(client) + assert "5.00" in body + assert "+1.00" in body + + +# ------------------------------------------- INCOMPLETE, RUNNING, and no record + + +def test_incomplete_coverage_withholds_the_mlb_average( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_two_teams(seed) + record_coverage(teams=2, failed=1) + body = runs_page(client) + + assert LEAGUE_RUNS_UNAVAILABLE_NOTE in body + assert "MLB Average" not in body + assert "runs per game across" not in body + + +def test_incomplete_coverage_still_renders_the_team_chart( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_two_teams(seed) + record_coverage(teams=2, failed=1) + body = runs_page(client) + + assert "team-runs-chart" in body + assert "Game Runs" in body + assert "15-Game Average" in body + assert "Team Season Average" in body + assert "Season Avg" in body + + +def test_running_coverage_behaves_like_incomplete( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + """A run that never finished leaves coverage unknown, so it is not trusted.""" + seed_two_teams(seed) + record_coverage(teams=2, finished=False) + body = runs_page(client) + + assert LEAGUE_RUNS_UNAVAILABLE_NOTE in body + assert "MLB Average" not in body + assert "Game Runs" in body + + +def test_no_coverage_record_behaves_like_incomplete( + client: TestClient, seed: SeedFn +) -> None: + seed_two_teams(seed) + body = runs_page(client) + + assert LEAGUE_RUNS_UNAVAILABLE_NOTE in body + assert "MLB Average" not in body + assert "Game Runs" in body + + +def test_the_unavailable_card_shows_a_dash_not_a_number( + client: TestClient, seed: SeedFn +) -> None: + seed_two_teams(seed) + body = runs_page(client) + assert "Comparison unavailable" in body + assert "+0.00" not in body + + +def test_coverage_for_another_season_does_not_unlock_this_one( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_two_teams(seed, season=2025) + seed([5] * 5, season=2026) + record_coverage(season=2025, teams=2) + + assert LEAGUE_RUNS_UNAVAILABLE_NOTE in runs_page(client, season=2026) + assert LEAGUE_RUNS_UNAVAILABLE_NOTE not in runs_page(client, season=2025) + + +# ----------------------------------------------------- in-progress 2026 season + + +def test_complete_coverage_of_a_partial_season_still_compares( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + """Far fewer rows than a full season, and the comparison is still allowed. + + Coverage says every discovered team was refreshed. Row count is never the + completeness rule, so 40 team-game records qualify exactly as 4,860 would. + """ + seed([6] * 20, team_id=MARINERS_ID, team_name=MARINERS_NAME, season=2026) + seed([2] * 20, team_id=TWINS_ID, team_name=TWINS_NAME, season=2026) + record_coverage(season=2026, teams=2) + + body = runs_page(client, season=2026) + assert "MLB Average" in body + assert "scored 4.00 runs per game" in body + assert "+2.00" in body + assert "40 team-game records" in body + + +def test_an_in_progress_season_is_not_described_as_finished( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed([6] * 20, team_id=MARINERS_ID, team_name=MARINERS_NAME, season=2026) + seed([2] * 20, team_id=TWINS_ID, team_name=TWINS_NAME, season=2026) + record_coverage(season=2026, teams=2) + + body = runs_page(client, season=2026) + assert "currently stored" in body + assert "not that the season has finished being played" in body + + +# --------------------------------------------- the rest of the page is intact + + +def test_the_team_and_season_selectors_still_work( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_two_teams(seed) + seed([4] * 10, season=2026) + record_coverage(teams=2) + + assert "Minnesota Twins — Runs Scored per Game" in runs_page( + client, team_id=TWINS_ID + ) + assert "2026 regular season" in runs_page(client, season=2026) + + +@pytest.mark.parametrize("window", [5, 10, 15, 30]) +def test_the_window_selector_still_works_with_mlb_context( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn, window: int +) -> None: + seed_two_teams(seed) + record_coverage(teams=2) + body = runs_page(client, window=window) + assert f"{window}-Game Average" in body + assert "MLB Average" in body + + +def test_shareable_query_parameters_still_round_trip( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_two_teams(seed) + record_coverage(teams=2) + body = runs_page(client, team_id=TWINS_ID, window=30) + assert "Minnesota Twins — Runs Scored per Game" in body + assert 'href="/?team_id=142&season=2025&window=30"' in body + assert 'href="/strikeouts?team_id=142&season=2025&window=30"' in body + + +def test_a_missing_team_season_is_still_handled_safely( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + """Complete coverage does not turn an unstored selection into an error.""" + seed_two_teams(seed) + record_coverage(teams=2) + response = client.get("/runs?team_id=999&season=2025") + assert response.status_code == 404 + assert "No games are stored for team id 999" in response.text + assert "Traceback" not in response.text + + +def test_the_hits_page_is_unchanged_by_the_runs_comparison( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + seed_two_teams(seed) + record_coverage(teams=2) + body = client.get(f"/?team_id={MARINERS_ID}&season=2025").text + assert "Seattle Mariners — Hits per Game" in body + assert "8.00 hits per game" in body + assert "Runs Scored per Game" not in body + + +def test_the_strikeouts_page_is_unchanged_by_the_runs_comparison( + client: TestClient, seed: SeedFn, record_coverage: CoverageFn +) -> None: + """These rows carry no strikeout totals, so that page keeps its own state.""" + seed_two_teams(seed) + record_coverage(teams=2) + response = client.get(f"/strikeouts?team_id={MARINERS_ID}&season=2025") + assert response.status_code == 409 + assert "needs to be re-imported" in response.text + assert "Runs Scored per Game" not in response.text + + +def test_health_is_unchanged(client: TestClient) -> None: + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +# ------------------------------------------------------------------ no MLB calls + + +def test_the_comparison_never_reaches_the_mlb_api( + client: TestClient, + seed: SeedFn, + record_coverage: CoverageFn, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail(*args: object, **kwargs: object) -> None: + raise AssertionError("The web layer must not reach the MLB Stats API") + + seed_two_teams(seed) + record_coverage(teams=2) + monkeypatch.setattr(requests.Session, "request", fail) + monkeypatch.setattr("mlbstatsapi.Mlb.__init__", fail) + monkeypatch.setattr("app.services.team_game_logs.get_team_game_batting_lines", fail) + monkeypatch.setattr("app.services.league_teams.discover_mlb_teams", fail) + monkeypatch.setattr( + "app.services.league_season_ingestion.ingest_league_season", fail + ) + + assert "MLB Average" in runs_page(client)