From 96062a7bdabf9b1c7ef81772253f6ee9767f0d28 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 23 Sep 2026 12:53:36 -0700 Subject: [PATCH 1/3] test: characterize shared team-season page lifecycle Pin the pre-analytics behavior every analytics route shares, across all eight routes: empty database, unknown team/season 404s, explicit and default selection, FastAPI 422 validation, and the missing-schema 503. Terminal states keep the requested query values in navigation; resolved selections rebuild navigation from the resolved values. Refs #44 Co-Authored-By: Claude Opus 5.5 --- tests/test_web_page_scaffolding.py | 454 +++++++++++++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 tests/test_web_page_scaffolding.py diff --git a/tests/test_web_page_scaffolding.py b/tests/test_web_page_scaffolding.py new file mode 100644 index 0000000..972c253 --- /dev/null +++ b/tests/test_web_page_scaffolding.py @@ -0,0 +1,454 @@ +"""Characterization tests for the team/season page lifecycle every analytics +route shares before it loads any metric-specific records. + +Each analytics page resolves a team, a season, and a rolling window from the +query string, handles the empty-database and not-found states, and carries the +reader's selection through the form and the navigation. That behavior is the +same on every page, so it is pinned here once per route. What each page does +after the selection is resolved (loading its own records, missing-data states, +league context, charts, cards) is covered by that route's own test module. + +The key contract: terminal states (empty, unknown team, unknown season) keep +the *requested* query values in the navigation, while a resolved selection +rebuilds the navigation from the *resolved* values. +""" + +import html +import re +from collections.abc import Callable, Generator, Iterator +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlencode + +import pytest +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, upsert_team_season_pitching +from app.main import create_app +from app.schemas.games import TeamGameBattingLine +from app.web.dependencies import get_db_session +from tests.factories import ( + MARINERS_ID, + MARINERS_NAME, + TWINS_ID, + TWINS_NAME, + make_batting_line, + make_pitching_season, + make_season, +) + +BROWSER_HEADERS = {"accept": "text/html,application/xhtml+xml"} +GAMES = 20 + + +@dataclass(frozen=True) +class AnalyticsPage: + path: str + heading: str + nav_label: str + + +PAGES = ( + AnalyticsPage("/", "Team Hitting Trends", "Hits"), + AnalyticsPage("/strikeouts", "Team Batting Strikeout Trends", "Batting Strikeouts"), + AnalyticsPage("/runs", "Team Run Scoring Trends", "Runs"), + AnalyticsPage("/baserunners", "Team Baserunners Trends", "Baserunners"), + AnalyticsPage("/run-differential", "Team Run Differential", "Run Differential"), + AnalyticsPage("/pitching", "Team Pitching Trends", "Pitching"), + AnalyticsPage("/hits-allowed", "Team Hits Allowed Trends", "Hits Allowed"), + AnalyticsPage("/comparison", "Team Hitting Trends Comparison", "Comparison"), +) +PAGE_PATHS = tuple(page.path for page in PAGES) + +_NAV_LINK_PATTERN = re.compile( + r'(?P' +) + + +@dataclass(frozen=True) +class RenderedNavLink: + label: str + href: str + is_current: bool + + +def rendered_nav_links(body: str) -> list[RenderedNavLink]: + links = [ + RenderedNavLink( + label=match.group("label"), + href=html.unescape(match.group("href")), + is_current=match.group("current") is not None, + ) + for match in _NAV_LINK_PATTERN.finditer(body) + ] + assert len(links) == len(PAGES), "the page did not render the full navigation" + return links + + +def assert_navigation(body: str, *, current: AnalyticsPage, query: str) -> None: + """Every link carries ``query``, and only ``current`` is marked current.""" + suffix = f"?{query}" if query else "" + links = rendered_nav_links(body) + assert [link.href for link in links] == [f"{page.path}{suffix}" for page in PAGES] + assert [link.label for link in links if link.is_current] == [current.nav_label] + assert body.count('aria-current="page"') == 1 + + +def expected_query(team_id: int | None, season: int | None, window: int) -> str: + selection: dict[str, int] = {} + if team_id is not None: + selection["team_id"] = team_id + if season is not None: + selection["season"] = season + selection["window"] = window + return urlencode(selection) + + +def page_for(path: str) -> AnalyticsPage: + return next(page for page in PAGES if page.path == path) + + +@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 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 opponent_rows(lines: list[TeamGameBattingLine]) -> list[TeamGameBattingLine]: + """The Twins' side of each Seattle game, as a league-wide import stores it.""" + return [ + make_batting_line( + game_pk=line.game_pk, + game_date=line.game_date, + season=line.season, + team_id=TWINS_ID, + team_name=TWINS_NAME, + opponent_id=MARINERS_ID, + opponent_name=MARINERS_NAME, + home_away="away" if line.home_away == "home" else "home", + runs=3, + strikeouts=9, + base_on_balls=2, + hit_by_pitch=0, + ) + for line in lines + ] + + +@pytest.fixture +def seeded(session_factory: Callable[[], Session]) -> None: + """Store enough for every page to reach its selected-team data. + + Seattle holds 2024 and 2025. Each season has batting lines with every + optional column known, pitching lines, and the Twins' rows for the same + games so run differential can pair them. The Twins are a second stored + team, so the default-team rule has a real choice to make. + """ + mariners_2025 = make_season( + hits=[8] * GAMES, + strikeouts=[7] * GAMES, + base_on_balls=[3] * GAMES, + hit_by_pitch=[1] * GAMES, + runs=[4] * GAMES, + ) + mariners_2024 = make_season( + hits=[6] * GAMES, + season=2024, + strikeouts=[8] * GAMES, + base_on_balls=[2] * GAMES, + hit_by_pitch=[0] * GAMES, + ) + session = session_factory() + try: + for mariners in (mariners_2025, mariners_2024): + upsert_team_season(session, lines=mariners) + upsert_team_season(session, lines=opponent_rows(mariners)) + upsert_team_season_pitching( + session, lines=make_pitching_season([3] * GAMES, season=2025) + ) + upsert_team_season_pitching( + session, lines=make_pitching_season([3] * GAMES, season=2024) + ) + session.commit() + finally: + session.close() + + +@pytest.mark.parametrize("path", PAGE_PATHS) +class TestEmptyDatabase: + def test_renders_the_route_empty_state(self, client: TestClient, path: str) -> None: + response = client.get(path) + assert response.status_code == 200 + assert f"

{page_for(path).heading}

" in response.text + assert "No team data has been imported yet" in response.text + assert "--team-id 136 --season 2025" in response.text + # No stored team means no selector form to fill in. + assert ' None: + """Guard the fixture: the selection tests above exercise a resolved page.""" + response = client.get(path) + assert response.status_code == 200 + assert SEEDED_OUTCOMES[path] in response.text From 847aa1901584fccc03642084ef1f36d94a061f6c Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 23 Sep 2026 12:53:36 -0700 Subject: [PATCH 2/3] refactor: share team-season page preparation across analytics routes Extract the duplicated route prefix (team-season lookup, 503/empty/404 handling, team and season resolution, navigation rebuild) into _prepare_team_season_page, returning a small PreparedTeamSeasonPage or a rendered terminal response. Declare the team_id/season/window query contract once as Annotated aliases. The abstraction stops before metric-specific record loading: every route's code from its first repository call onward is unchanged, and the generated OpenAPI schema is identical. Refs #44 Co-Authored-By: Claude Opus 5.5 --- app/web/routes.py | 893 ++++++++++----------------------- docs/architecture-deep-dive.md | 36 +- 2 files changed, 295 insertions(+), 634 deletions(-) diff --git a/app/web/routes.py b/app/web/routes.py index cd678ca..cc84ebd 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -4,6 +4,7 @@ reached exclusively from the import CLI. """ +from dataclasses import dataclass from typing import Annotated, Any, Literal, get_args from fastapi import APIRouter, Depends, Query, Request, Response @@ -145,6 +146,7 @@ build_nav_links, ) from app.web.selection import ( + TeamOption, build_team_options, build_team_seasons_catalog, select_season, @@ -171,6 +173,21 @@ def _coerce_window(value: object) -> object: RollingWindowParam = Annotated[RollingWindow, BeforeValidator(_coerce_window)] +# The query contract every team-season analytics page accepts. Declared once so +# the eight routes cannot drift apart; FastAPI still validates each parameter. +TeamIdQuery = Annotated[ + int | None, + Query(gt=0, description="MLB team id that has been imported locally."), +] +SeasonQuery = Annotated[ + int | None, + Query(gt=0, description="Season that has been imported for the team."), +] +RollingWindowQuery = Annotated[ + RollingWindowParam, + Query(description="Games in the trailing rolling average."), +] + PLOTLY_BUNDLE_PATH = "/vendor/plotly.min.js" # Club and league marks are fetched by the browser from MLB's public logo # host, keyed by the same team ids the application already stores. They are @@ -204,84 +221,28 @@ def create_router(templates: Jinja2Templates, settings: Settings) -> APIRouter: def index( 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, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render team hitting 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": HITS_PATH, - "nav_links": build_nav_links( - current_path=HITS_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="index.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="index.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="index.html", context=context, status_code=404 - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=HITS_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=HITS_PATH, + template_name="index.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season( session, team_id=selected_team.team_id, season=selected_season ) @@ -311,90 +272,28 @@ def index( def strikeouts( 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, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render batting strikeout 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": STRIKEOUTS_PATH, - "nav_links": build_nav_links( - current_path=STRIKEOUTS_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="strikeouts.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="strikeouts.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="strikeouts.html", - context=context, - status_code=404, - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=STRIKEOUTS_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=STRIKEOUTS_PATH, + template_name="strikeouts.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season( session, team_id=selected_team.team_id, season=selected_season ) @@ -459,84 +358,28 @@ def strikeouts( 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, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = 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, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=RUNS_PATH, + template_name="runs.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season( session, team_id=selected_team.team_id, season=selected_season ) @@ -564,90 +407,28 @@ def runs( def baserunners( 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, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render baserunners 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": BASERUNNERS_PATH, - "nav_links": build_nav_links( - current_path=BASERUNNERS_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="baserunners.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="baserunners.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="baserunners.html", - context=context, - status_code=404, - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=BASERUNNERS_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=BASERUNNERS_PATH, + template_name="baserunners.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season( session, team_id=selected_team.team_id, season=selected_season ) @@ -712,90 +493,28 @@ def baserunners( def run_differential( 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, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render run differential and Pythagorean record for one 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": RUN_DIFFERENTIAL_PATH, - "nav_links": build_nav_links( - current_path=RUN_DIFFERENTIAL_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="run_differential.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="run_differential.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="run_differential.html", - context=context, - status_code=404, - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=RUN_DIFFERENTIAL_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=RUN_DIFFERENTIAL_PATH, + template_name="run_differential.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season run_results = list_team_season_run_results( session, team_id=selected_team.team_id, season=selected_season ) @@ -852,90 +571,28 @@ def run_differential( def hits_allowed( 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, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render hits-allowed 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": HITS_ALLOWED_PATH, - "nav_links": build_nav_links( - current_path=HITS_ALLOWED_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="hits_allowed.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="hits_allowed.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="hits_allowed.html", - context=context, - status_code=404, - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=HITS_ALLOWED_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=HITS_ALLOWED_PATH, + template_name="hits_allowed.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season_pitching( session, team_id=selected_team.team_id, season=selected_season ) @@ -987,90 +644,28 @@ def hits_allowed( def pitching( 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, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render pitching 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": PITCHING_PATH, - "nav_links": build_nav_links( - current_path=PITCHING_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="pitching.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="pitching.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="pitching.html", - context=context, - status_code=404, - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=PITCHING_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=PITCHING_PATH, + template_name="pitching.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season_pitching( session, team_id=selected_team.team_id, season=selected_season ) @@ -1122,90 +717,28 @@ def pitching( def hitting_comparison( 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, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render normalized rolling Hits/Game and batting K/Game trends.""" - 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": COMPARISON_PATH, - "nav_links": build_nav_links( - current_path=COMPARISON_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="comparison.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="comparison.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="comparison.html", - context=context, - status_code=404, - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=COMPARISON_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=COMPARISON_PATH, + template_name="comparison.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season( session, team_id=selected_team.team_id, season=selected_season ) @@ -1490,6 +1023,110 @@ def _load_league_baserunners_comparison( return compare_team_baserunners_to_league(analysis, league) +@dataclass(frozen=True) +class PreparedTeamSeasonPage: + """A team-season selection that resolved to stored data. + + ``context`` holds the shared template values, with the navigation already + rebuilt from the resolved team and season. The route adds its own records, + analysis, and state to it. Nothing metric-specific is loaded here. + """ + + context: dict[str, Any] + team: TeamOption + season: int + + +def _prepare_team_season_page( + templates: Jinja2Templates, + settings: Settings, + request: Request, + session: Session, + *, + path: str, + template_name: str, + team_id: int | None, + season: int | None, + window: RollingWindow, +) -> PreparedTeamSeasonPage | Response: + """Resolve the team-season every analytics page needs before its own data. + + Returns the rendered page instead when there is nothing to analyse: the + schema is missing (503), nothing is stored yet, or the requested team or + season is not stored (404). Those terminal pages keep the *requested* + values in the navigation, so the reader's link is carried forward as they + typed it. Only a selection that resolves has its navigation rebuilt from + the *resolved* values, which is how defaults become shareable links. + """ + 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": path, + "nav_links": build_nav_links( + current_path=path, + team_id=team_id, + season=season, + window=window, + ), + } + + if not teams: + context["state"] = "empty" + return templates.TemplateResponse( + request=request, name=template_name, 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=template_name, 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=template_name, context=context, status_code=404 + ) + + context["selected_season"] = selected_season + context["nav_links"] = build_nav_links( + current_path=path, + team_id=selected_team.team_id, + season=selected_season, + window=window, + ) + return PreparedTeamSeasonPage( + context=context, team=selected_team, season=selected_season + ) + + def _render_comparison_unavailable( templates: Jinja2Templates, request: Request, diff --git a/docs/architecture-deep-dive.md b/docs/architecture-deep-dive.md index 0b96d16..f028a6c 100644 --- a/docs/architecture-deep-dive.md +++ b/docs/architecture-deep-dive.md @@ -234,23 +234,47 @@ scattered inline: ## 5. Web routes — `app/web/routes.py` -Four page routes (`/`, `/strikeouts`, `/runs`, `/comparison`) share one -skeleton, repeated per route rather than factored into a shared helper -(again, a deliberate legibility tradeoff — each route's docstring and -error branches read as one linear story): +When this was written, four page routes (`/`, `/strikeouts`, `/runs`, +`/comparison`) shared one skeleton, repeated per route rather than +factored into a shared helper (a deliberate legibility tradeoff — each +route's docstring and error branches read as one linear story). + +**Update (2026-09, issue #44):** by eight analytics routes (adding +`/baserunners`, `/run-differential`, `/pitching`, `/hits-allowed`) the +copied prefix had become the part most likely to drift, so it was +extracted. The split is at the point where a route first needs its own +records: ``` +── shared: _prepare_team_season_page(...) ──────────────────────────────── +TeamIdQuery / SeasonQuery / RollingWindowQuery → FastAPI validation (422) list_available_team_seasons(session) → DatabaseSchemaMissingError → 503 page telling you to run alembic build_team_options(available) → empty? render "no data yet" state select_team(teams, team_id) → not found? → 404 page naming stored teams select_season(selected_team, season) → not found? → 404 page naming stored seasons -list_team_season(session, ...) → rows for the chosen team-season -build_team_*_analysis(games, window) → pure analytics call +rebuild nav links from resolved values → PreparedTeamSeasonPage(context, team, season) +── explicit in each route ──────────────────────────────────────────────── +list_team_season(session, ...) → rows for the chosen team-season (or pitching / run results) +build_team_*_analysis(games, window) → pure analytics call; route-specific 409 / unavailable states _load_league_*_comparison(session, …) → None unless coverage is COMPLETE build_team_*_figure(analysis, league) → Plotly figure render + return TemplateResponse ``` +The helper returns either the prepared selection or an already-rendered +terminal page, so each route reads `prepared = ...; if isinstance(prepared, +Response): return prepared` and then continues with its own story. It +deliberately takes no callbacks or metric configuration: record loading, +missing-data handling, league gating, and cards stay in the route, because +those are exactly where the pages differ (run differential's opponent +pairing, pitching's separate table, comparison's layered unavailability). + +One subtlety the helper preserves: terminal pages (empty, unknown team, +unknown season) build navigation from the *requested* query values, and +only a resolved selection rebuilds it from the *resolved* values. That is +why the navigation is built twice. `tests/test_web_page_scaffolding.py` +pins this shared contract across all eight routes. + Every branch point returns a real, styled page (via `app/web/templates/error.html` or the page's own template with a `state` context flag: `empty` / `not_found` / `missing_strikeouts` / `unavailable` From 5f353610dd3bda2a8ebb01c476b40bd128f8638a Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 23 Sep 2026 13:22:07 -0700 Subject: [PATCH 3/3] test: tighten route scaffolding regression coverage Group the shared team-season scaffold assertions so each analytics route makes all checks for one state against one migrated database, instead of one tiny test per assertion. Every contract is still pinned on all eight routes, including the requested-vs-resolved navigation distinction. Add a parametrized DB-only regression proving each analytics browser route renders from persisted data without constructing an MLB client or calling the network-facing services, using the same monkeypatch pattern as the existing route suites. Co-Authored-By: Claude Opus 5.5 --- tests/test_web_page_scaffolding.py | 417 +++++++++++++++-------------- 1 file changed, 210 insertions(+), 207 deletions(-) diff --git a/tests/test_web_page_scaffolding.py b/tests/test_web_page_scaffolding.py index 972c253..4531624 100644 --- a/tests/test_web_page_scaffolding.py +++ b/tests/test_web_page_scaffolding.py @@ -11,6 +11,9 @@ The key contract: terminal states (empty, unknown team, unknown season) keep the *requested* query values in the navigation, while a resolved selection rebuilds the navigation from the *resolved* values. + +Every test builds a freshly migrated SQLite database, so each test makes all +the assertions for one route and one state rather than one assertion apiece. """ import html @@ -21,6 +24,7 @@ from urllib.parse import urlencode import pytest +import requests from fastapi.testclient import TestClient from sqlalchemy.orm import Session @@ -40,8 +44,11 @@ ) BROWSER_HEADERS = {"accept": "text/html,application/xhtml+xml"} +JSON_HEADERS = {"accept": "application/json"} GAMES = 20 +# --- Page definitions --------------------------------------------------------- + @dataclass(frozen=True) class AnalyticsPage: @@ -62,6 +69,40 @@ class AnalyticsPage: ) PAGE_PATHS = tuple(page.path for page in PAGES) +# What each page shows once the seeded selection reaches its own data. The +# comparison page has no COMPLETE league coverage in this fixture, so its +# route-specific "unavailable" state is the expected outcome there. +SEEDED_OUTCOMES = { + "/": "Seattle Mariners — Hits per Game", + "/strikeouts": "Seattle Mariners — Batting Strikeouts per Game", + "/runs": "Seattle Mariners — Runs Scored per Game", + "/baserunners": "Seattle Mariners — Baserunners per Game", + "/run-differential": "Seattle Mariners — Run Differential per Game", + "/pitching": "Seattle Mariners — Pitches per Game", + "/hits-allowed": "Seattle Mariners — Hits Allowed per Game", + "/comparison": "Normalized comparison unavailable", +} + +# Values FastAPI must reject before any route code runs, with the query +# parameter the 422 detail must name. +INVALID_QUERIES = ( + ("team_id=0", "team_id"), + ("team_id=-3", "team_id"), + ("team_id=banana", "team_id"), + ("season=0", "season"), + ("season=-2025", "season"), + ("season=20.5", "season"), + ("window=7", "window"), + ("window=banana", "window"), +) + + +def page_for(path: str) -> AnalyticsPage: + return next(page for page in PAGES if page.path == path) + + +# --- Navigation assertion helpers --------------------------------------------- + _NAV_LINK_PATTERN = re.compile( r'(?P' @@ -107,8 +148,7 @@ def expected_query(team_id: int | None, season: int | None, window: int) -> str: return urlencode(selection) -def page_for(path: str) -> AnalyticsPage: - return next(page for page in PAGES if page.path == path) +# --- Fixtures ----------------------------------------------------------------- @pytest.fixture @@ -136,6 +176,26 @@ def override_session() -> Iterator[Session]: return TestClient(app) +@pytest.fixture +def unmigrated_client(tmp_path: Path) -> Generator[TestClient]: + 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: + yield TestClient(app) + finally: + engine.dispose() + + def opponent_rows(lines: list[TeamGameBattingLine]) -> list[TeamGameBattingLine]: """The Twins' side of each Seattle game, as a league-wide import stores it.""" return [ @@ -196,220 +256,160 @@ def seeded(session_factory: Callable[[], Session]) -> None: session.close() +# --- Empty database (terminal state: requested values) ------------------------ + + @pytest.mark.parametrize("path", PAGE_PATHS) -class TestEmptyDatabase: - def test_renders_the_route_empty_state(self, client: TestClient, path: str) -> None: - response = client.get(path) - assert response.status_code == 200 - assert f"

{page_for(path).heading}

" in response.text - assert "No team data has been imported yet" in response.text - assert "--team-id 136 --season 2025" in response.text - # No stored team means no selector form to fill in. - assert ' None: - response = client.get(f"{path}?team_id=999&season=2025&window=10") - assert response.status_code == 404 - assert f"

{page_for(path).heading}

" in response.text - assert "That team-season is not stored locally" in response.text - assert ( - "No games are stored for team id 999. " - "Pick a team that has been imported, or import that team." - ) in response.text - - def test_keeps_the_team_selector_populated_with_nothing_selected( - self, client: TestClient, path: str - ) -> None: - body = client.get(f"{path}?team_id=999&season=2025&window=10").text - assert f'' in body - assert f'' in body - assert '' in body - assert f'action="{path}"' in body - - def test_navigation_carries_the_requested_values( - self, client: TestClient, path: str - ) -> None: - body = client.get(f"{path}?team_id=999&season=2025&window=10").text - assert_navigation( - body, current=page_for(path), query=expected_query(999, 2025, 10) - ) +def test_unknown_team_is_a_404_that_keeps_the_requested_values( + client: TestClient, path: str +) -> None: + page = page_for(path) + + response = client.get(f"{path}?team_id=999&season=2025&window=10") + assert response.status_code == 404 + body = response.text + assert f"

{page.heading}

" in body + assert "That team-season is not stored locally" in body + assert ( + "No games are stored for team id 999. " + "Pick a team that has been imported, or import that team." + ) in body + # The team selector stays populated, with no stored team selected. + assert f'' in body + assert f'' in body + assert '' in body + assert f'action="{path}"' in body + assert_navigation(body, current=page, query=expected_query(999, 2025, 10)) + + # A season that was never requested is not invented for the navigation. + without_season = client.get(f"{path}?team_id=999") + assert without_season.status_code == 404 + assert_navigation( + without_season.text, current=page, query=expected_query(999, None, 15) + ) - def test_navigation_omits_a_season_that_was_not_requested( - self, client: TestClient, path: str - ) -> None: - body = client.get(f"{path}?team_id=999").text - assert_navigation( - body, current=page_for(path), query=expected_query(999, None, 15) - ) + +# --- Unknown season (terminal state: requested values) ------------------------ @pytest.mark.parametrize("path", PAGE_PATHS) @pytest.mark.usefixtures("seeded") -class TestUnknownSeason: - def test_is_a_404_listing_the_stored_seasons( - self, client: TestClient, path: str - ) -> None: - response = client.get(f"{path}?team_id=136&season=1998&window=30") - assert response.status_code == 404 - assert f"

{page_for(path).heading}

" in response.text - assert ( - "No 1998 games are stored for Seattle Mariners. Stored seasons: 2025, 2024." - ) in response.text - - def test_keeps_the_team_selected_and_its_seasons_offered( - self, client: TestClient, path: str - ) -> None: - body = client.get(f"{path}?team_id=136&season=1998&window=30").text - assert f'' in ( - body - ) - assert '' in body - assert '' in body - assert '' in body - - def test_navigation_carries_the_requested_values( - self, client: TestClient, path: str - ) -> None: - body = client.get(f"{path}?team_id=136&season=1998&window=30").text - assert_navigation( - body, current=page_for(path), query=expected_query(136, 1998, 30) - ) +def test_unknown_season_is_a_404_that_keeps_the_requested_values( + client: TestClient, path: str +) -> None: + page = page_for(path) + + response = client.get(f"{path}?team_id=136&season=1998&window=30") + assert response.status_code == 404 + body = response.text + assert f"

{page.heading}

" in body + assert ( + "No 1998 games are stored for Seattle Mariners. Stored seasons: 2025, 2024." + ) in body + # The team stays selected and its stored seasons stay on offer. + assert f'' in body + assert '' in body + assert '' in body + assert '' in body + assert_navigation(body, current=page, query=expected_query(136, 1998, 30)) + + +# --- Explicit selection (resolved values) ------------------------------------- @pytest.mark.parametrize("path", PAGE_PATHS) @pytest.mark.usefixtures("seeded") -class TestExplicitSelection: - def test_form_reflects_the_selection_and_posts_to_this_page( - self, client: TestClient, path: str - ) -> None: - response = client.get(f"{path}?team_id=136&season=2024&window=5") - assert response.status_code == 200 - body = response.text - assert f'
' in body - assert f'' in ( - body - ) - assert '' in body - assert '' in body - - def test_navigation_preserves_the_complete_query( - self, client: TestClient, path: str - ) -> None: - body = client.get(f"{path}?team_id=136&season=2024&window=5").text - assert_navigation( - body, current=page_for(path), query=expected_query(136, 2024, 5) - ) +def test_explicit_selection_is_reflected_in_the_form_and_navigation( + client: TestClient, path: str +) -> None: + response = client.get(f"{path}?team_id=136&season=2024&window=5") + assert response.status_code == 200 + body = response.text + assert f'' in body + assert f'' in body + assert '' in body + assert '' in body + assert_navigation(body, current=page_for(path), query=expected_query(136, 2024, 5)) + + +# --- Default selection (resolved values) -------------------------------------- @pytest.mark.parametrize("path", PAGE_PATHS) @pytest.mark.usefixtures("seeded") -class TestDefaultSelection: - def test_resolves_seattle_and_its_newest_season( - self, client: TestClient, path: str - ) -> None: - response = client.get(path) - assert response.status_code == 200 - assert f'' in ( - response.text - ) - assert '' in response.text - assert '' in response.text - - def test_navigation_is_rebuilt_from_the_resolved_values( - self, client: TestClient, path: str - ) -> None: - body = client.get(path).text - assert_navigation( - body, current=page_for(path), query=expected_query(136, 2025, 15) - ) +def test_default_selection_resolves_seattle_and_its_newest_season( + client: TestClient, path: str +) -> None: + page = page_for(path) - def test_a_requested_team_resolves_its_newest_season( - self, client: TestClient, path: str - ) -> None: - body = client.get(f"{path}?team_id=136&window=30").text - assert_navigation( - body, current=page_for(path), query=expected_query(136, 2025, 30) - ) + response = client.get(path) + assert response.status_code == 200 + body = response.text + assert f'' in body + assert '' in body + assert '' in body + # The navigation is rebuilt from what was resolved, not from the empty query. + assert_navigation(body, current=page, query=expected_query(136, 2025, 15)) + # Guard the fixture: the selection tests exercise a resolved page, not a + # terminal state that happens to render the same form. + assert SEEDED_OUTCOMES[path] in body + + # A requested team without a season resolves that team's newest season. + team_only = client.get(f"{path}?team_id=136&window=30") + assert team_only.status_code == 200 + assert_navigation(team_only.text, current=page, query=expected_query(136, 2025, 30)) + + +# --- Query validation --------------------------------------------------------- @pytest.mark.parametrize("path", PAGE_PATHS) -@pytest.mark.parametrize( - ("query", "parameter"), - [ - ("team_id=0", "team_id"), - ("team_id=-3", "team_id"), - ("season=0", "season"), - ("season=-2025", "season"), - ("window=7", "window"), - ("window=banana", "window"), - ("team_id=banana", "team_id"), - ("season=20.5", "season"), - ], -) -class TestInvalidQueryValues: - def test_is_rejected_by_fastapi_validation( - self, client: TestClient, path: str, query: str, parameter: str - ) -> None: - response = client.get(f"{path}?{query}", headers={"accept": "application/json"}) - assert response.status_code == 422 - assert response.json()["detail"][0]["loc"] == ["query", parameter] - - def test_browsers_get_the_readable_error_page( - self, client: TestClient, path: str, query: str, parameter: str - ) -> None: - response = client.get(f"{path}?{query}", headers=BROWSER_HEADERS) - assert response.status_code == 422 - assert "That link has a value this page cannot use" in response.text - assert f"{parameter}: " in response.text - assert "Traceback" not in response.text - - -def test_window_error_lists_the_allowed_values(client: TestClient) -> None: - for path in PAGE_PATHS: - response = client.get(f"{path}?window=7", headers=BROWSER_HEADERS) - assert "Input should be 5, 10, 15 or 30" in response.text +def test_invalid_query_values_are_rejected_by_fastapi_validation( + client: TestClient, path: str +) -> None: + for query, parameter in INVALID_QUERIES: + url = f"{path}?{query}" + api_response = client.get(url, headers=JSON_HEADERS) + assert api_response.status_code == 422, url + assert api_response.json()["detail"][0]["loc"] == ["query", parameter], url -@pytest.fixture -def unmigrated_client(tmp_path: Path) -> Generator[TestClient]: - engine = build_engine(f"sqlite:///{tmp_path / 'unmigrated.db'}") - factory = build_session_factory(engine) - app = create_app() + browser_response = client.get(url, headers=BROWSER_HEADERS) + assert browser_response.status_code == 422, url + assert "That link has a value this page cannot use" in browser_response.text + assert f"{parameter}: " in browser_response.text, url + assert "Traceback" not in browser_response.text, url - def override_session() -> Iterator[Session]: - session = factory() - try: - yield session - finally: - session.close() + window_response = client.get(f"{path}?window=7", headers=BROWSER_HEADERS) + assert "Input should be 5, 10, 15 or 30" in window_response.text - app.dependency_overrides[get_db_session] = override_session - try: - yield TestClient(app) - finally: - engine.dispose() + +# --- Missing schema ----------------------------------------------------------- @pytest.mark.parametrize("path", PAGE_PATHS) @@ -428,27 +428,30 @@ def test_missing_schema_renders_the_migration_error_page( assert 'aria-label="Metrics"' not in body -# What each page shows once the seeded selection reaches its own data. The -# comparison page has no COMPLETE league coverage in this fixture, so its -# route-specific "unavailable" state is the expected outcome there. -SEEDED_OUTCOMES = { - "/": "Seattle Mariners — Hits per Game", - "/strikeouts": "Seattle Mariners — Batting Strikeouts per Game", - "/runs": "Seattle Mariners — Runs Scored per Game", - "/baserunners": "Seattle Mariners — Baserunners per Game", - "/run-differential": "Seattle Mariners — Run Differential per Game", - "/pitching": "Seattle Mariners — Pitches per Game", - "/hits-allowed": "Seattle Mariners — Hits Allowed per Game", - "/comparison": "Normalized comparison unavailable", -} +# --- DB-only browser rendering ------------------------------------------------ @pytest.mark.parametrize("path", PAGE_PATHS) @pytest.mark.usefixtures("seeded") -def test_seeded_selection_reaches_the_route_specific_page( - client: TestClient, path: str +def test_browser_rendering_never_calls_the_mlb_api( + client: TestClient, path: str, monkeypatch: pytest.MonkeyPatch ) -> None: - """Guard the fixture: the selection tests above exercise a resolved page.""" - response = client.get(path) + """Web requests read persisted data only; every MLB entry point fails loudly.""" + + 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("mlbstatsapi.AsyncMlb.__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 + ) + + response = client.get( + f"{path}?team_id=136&season=2025&window=15", headers=BROWSER_HEADERS + ) assert response.status_code == 200 assert SEEDED_OUTCOMES[path] in response.text