diff --git a/app/database/repositories.py b/app/database/repositories.py index d78bf41..27cfcc3 100644 --- a/app/database/repositories.py +++ b/app/database/repositories.py @@ -509,6 +509,25 @@ def get_player(session: Session, *, player_id: int) -> PlayerIdentity | None: return None if record is None else record.to_domain() +def list_player_catalog_seasons(session: Session) -> list[int]: + """Return catalog membership seasons, newest first, independently of stats.""" + stmt = ( + select(PlayerSeasonCatalogRecord.season) + .distinct() + .order_by(PlayerSeasonCatalogRecord.season.desc()) + ) + try: + return list(session.scalars(stmt)) + except OperationalError as exc: + message = str(exc.orig if exc.orig is not None else exc).lower() + if "no such table" not in message or "player_seasons" not in message: + raise + raise DatabaseSchemaMissingError( + "Table 'player_seasons' is missing. " + f"Apply migrations with: {MIGRATION_HINT}" + ) from exc + + def list_player_catalog( session: Session, *, season: int ) -> list[PlayerSeasonCatalogEntry]: diff --git a/app/main.py b/app/main.py index ece876f..708cf9f 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ from app.config import get_settings from app.database.engine import build_engine, build_session_factory from app.web.errors import create_validation_error_handler +from app.web.player_routes import create_player_router from app.web.routes import create_router WEB_DIR = Path(__file__).resolve().parent / "web" @@ -52,6 +53,7 @@ def create_app() -> FastAPI: create_validation_error_handler(templates, settings), ) application.include_router(create_router(templates, settings)) + application.include_router(create_player_router(templates, settings)) return application diff --git a/app/schemas/players.py b/app/schemas/players.py index a19addc..573a3af 100644 --- a/app/schemas/players.py +++ b/app/schemas/players.py @@ -7,6 +7,14 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator +def normalize_player_name(name: str) -> str: + """Fold case and accents consistently for catalog ordering and search.""" + decomposed = unicodedata.normalize("NFKD", name) + return "".join( + character for character in decomposed if not unicodedata.combining(character) + ).casefold() + + class PlayerIdentity(BaseModel): """A player's persisted identity fields. @@ -36,13 +44,7 @@ def name_sort_key(self) -> tuple[str, str, int]: the same order. ``full_name`` breaks ties between names that fold together (``Zoë`` and ``zoë``) and ``player_id`` makes the result total. """ - decomposed = unicodedata.normalize("NFKD", self.full_name) - folded = "".join( - character - for character in decomposed - if not unicodedata.combining(character) - ).casefold() - return (folded, self.full_name, self.player_id) + return (normalize_player_name(self.full_name), self.full_name, self.player_id) class PlayerSeasonCatalogEntry(PlayerIdentity): diff --git a/app/web/player_routes.py b/app/web/player_routes.py new file mode 100644 index 0000000..1f2aeeb --- /dev/null +++ b/app/web/player_routes.py @@ -0,0 +1,122 @@ +"""DB-only Player directory, search, and season-scoped selection.""" + +from typing import Annotated +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from app.config import Settings +from app.database.repositories import ( + MIGRATION_HINT, + DatabaseSchemaMissingError, + list_player_catalog, + list_player_catalog_seasons, +) +from app.schemas.players import PlayerSeasonCatalogEntry, normalize_player_name +from app.web.dependencies import get_db_session +from app.web.routes import MLB_LOGO_URL + +# A fixed cap keeps broad searches readable without a pagination framework. +PLAYER_SEARCH_LIMIT = 50 +PLAYER_CATALOG_IMPORT_COMMAND = ( + "poetry run python scripts/import_player_catalog.py --season {season}" +) + + +def search_player_catalog( + catalog: list[PlayerSeasonCatalogEntry], query: str +) -> list[PlayerSeasonCatalogEntry]: + """Return catalog members whose name contains the query, keeping catalog order. + + Uses the catalog's own case and accent folding, so ``rodriguez`` finds + ``Rodríguez``. An empty query matches nobody rather than everybody. + """ + folded_query = normalize_player_name(query) + if not folded_query: + return [] + return [ + player + for player in catalog + if folded_query in normalize_player_name(player.full_name) + ] + + +def create_player_router(templates: Jinja2Templates, settings: Settings) -> APIRouter: + """Build the one real Player-domain destination.""" + router = APIRouter() + + @router.get("/players", response_class=HTMLResponse) + def player_directory( + request: Request, + session: Annotated[Session, Depends(get_db_session)], + season: Annotated[int | None, Query(gt=0)] = None, + q: str = "", + player_id: Annotated[int | None, Query(gt=0)] = None, + ) -> HTMLResponse: + seasons: list[int] = [] + catalog: list[PlayerSeasonCatalogEntry] = [] + message = None + status = 200 + schema_missing = False + try: + seasons = list_player_catalog_seasons(session) + if season is None and seasons: + season = seasons[0] + if season is not None and season in seasons: + catalog = list_player_catalog(session, season=season) + elif season is not None: + message = f"No Player catalog is stored locally for {season}." + status = 404 + except DatabaseSchemaMissingError as exc: + message = str(exc) + schema_missing = True + status = 503 + + # Membership is checked against the whole selected-season catalog, + # independently of the search text and the rendered result limit. + selected_player = next( + (player for player in catalog if player.player_id == player_id), None + ) + if player_id is not None and selected_player is None and status == 200: + message = f"Player {player_id} is not in the locally stored catalog" + ( + f" for {season}." if season is not None else "." + ) + status = 404 + + query = q.strip() + matches = search_player_catalog(catalog, query) + results = matches[:PLAYER_SEARCH_LIMIT] + selection_links = { + player.player_id: "/players?" + + urlencode({"season": season, "q": query, "player_id": player.player_id}) + for player in results + } + return templates.TemplateResponse( + request=request, + name="players.html", + context={ + "app_name": settings.app_name, + "mlb_logo_url": MLB_LOGO_URL, + "seasons": seasons, + "season": season, + "q": query, + "has_search": bool(normalize_player_name(query)), + "results": results, + "selection_links": selection_links, + "result_count": len(matches), + "result_limit": PLAYER_SEARCH_LIMIT, + "selected_player": selected_player, + "message": message, + "schema_missing": schema_missing, + "migration_command": MIGRATION_HINT, + "import_command": PLAYER_CATALOG_IMPORT_COMMAND.format( + season=season if season is not None else "" + ), + }, + status_code=status, + ) + + return router diff --git a/app/web/static/css/app.css b/app/web/static/css/app.css index 8702eee..4b62f62 100644 --- a/app/web/static/css/app.css +++ b/app/web/static/css/app.css @@ -359,6 +359,62 @@ body { background: var(--navy-deep); } +/* Player directory */ + +.player-search { + width: 100%; + min-width: 0; + padding: 0.62rem 0.8rem; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + color: var(--text); + font: inherit; + font-size: 0.95rem; +} + +.player-search:focus-visible, +.player-results__list a:focus-visible { + outline: 2px solid var(--teal); + outline-offset: 2px; +} + +.player-results__list { + list-style: none; + padding: 0; + margin: 0 0 0.8rem; +} + +.player-results__list li { + padding: 0.7rem 0; + border-bottom: 1px solid var(--border); + overflow-wrap: anywhere; +} + +.player-results__list a { + color: var(--navy); + font-weight: 600; + text-underline-offset: 0.2em; +} + +.player-results__list a:hover { + color: var(--teal); +} + +.player-selection { + border-left: 4px solid var(--teal); +} + +.player-selection p, +.player-results p { + overflow-wrap: anywhere; +} + +.player-directory-note { + color: var(--muted); + font-size: 0.85rem; +} + /* Chart */ .chart-card { diff --git a/app/web/templates/base.html b/app/web/templates/base.html index ab4bcd9..7b3b741 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -20,6 +20,7 @@ diff --git a/app/web/templates/players.html b/app/web/templates/players.html new file mode 100644 index 0000000..cfcbbc8 --- /dev/null +++ b/app/web/templates/players.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} + +{% block title %}Player Directory — {{ app_name }}{% endblock %} + +{% block primary_navigation %} + Teams + Players +{% endblock %} + +{% block content %} +
+

Player Directory

+

Find a player from the locally stored MLB catalog.

+
+ + {% if seasons %} +
+
+
+ + +
+
+ + +
+
+
+
+ {% endif %} + + {% if schema_missing %} +
+

The database schema is not ready

+

{{ message }}

+
{{ migration_command }}
+
+ {% else %} + {% if not seasons %} +
+

No Player catalog is stored locally

+ {% if message %}

{{ message }}

{% endif %} +

Import a season's catalog from the command line, then reload this page.

+ {% if season is none %}

Replace <season> with the season you want to import.

{% endif %} +
{{ import_command }}
+
+ {% endif %} + + {% if message and seasons %} +
+

That Player selection is not stored locally

+

{{ message }}

+

Stored seasons: {{ seasons | join(', ') }}.

+
+ {% endif %} + + {% if selected_player %} +
+

Selected Player

+

You selected {{ selected_player.full_name }} for the {{ season }} catalog.

+

Primary position: {{ selected_player.primary_position }}

+

Position is the stored identity value, not a historical position for this season.

+
+ {% endif %} + + {% if season in seasons %} +
+ {% if has_search %} +

Search results

+ {% if results %} +

{{ result_count }} matching player{% if result_count != 1 %}s{% endif %} in the {{ season }} stored catalog.

+ {% if result_count > result_limit %} +

Showing the first {{ result_limit }} results alphabetically. Refine your search to find a player.

+ {% endif %} + + {% else %} +

No players match “{{ q }}” in the {{ season }} stored catalog. Try another name or season.

+ {% endif %} + {% else %} +

Find a player

+

Search by full name or part of a name in the {{ season }} stored catalog. Capitalization and accents do not matter.

+ {% endif %} +

Only locally stored catalog members are shown. This directory does not establish complete season coverage.

+
+ {% endif %} + {% endif %} +{% endblock %} diff --git a/app/web/templates/team_base.html b/app/web/templates/team_base.html index 92ce7dd..eec401e 100644 --- a/app/web/templates/team_base.html +++ b/app/web/templates/team_base.html @@ -3,6 +3,7 @@ {% block primary_navigation %} {# Hits is the Team entry point; reuse the scaffold's resolved/requested URL. #} Teams + Players {% endblock %} {% block domain_navigation %} diff --git a/docs/ui-information-architecture.md b/docs/ui-information-architecture.md index 6d60f45..b4609ed 100644 --- a/docs/ui-information-architecture.md +++ b/docs/ui-information-architecture.md @@ -1,8 +1,7 @@ # UI information architecture -Issue #30, implementation Slice A separates application domains from Team metrics. -The primary navigation currently contains only **Teams**. Players stays hidden -until a real destination exists; no placeholder links or pages are rendered. +Issue #30 separates application domains from Team metrics. Primary navigation +contains **Teams** and **Players**, both leading to real destinations. Team analytics uses ordinary document links under noninteractive headings: @@ -18,13 +17,13 @@ All existing URLs remain canonical, with no aliases or redirects. `base.html` owns the entity-neutral shell, primary navigation, skip link, main landmark, and footer. `team_base.html` supplies the active Team domain and grouped navigation through `_team_navigation.html`. Individual metric templates retain -selectors, charts, interpretation, and recovery states. A future Player UI can -extend the same shell without copying Team assumptions. +selectors, charts, interpretation, and recovery states. `players.html` extends +the same shell directly, with its own season/search form and no secondary nav. Team metric pages include `_team_selector_form.html` for their Team, season, and rolling-window GET controls. It relies on the Team-season catalog and stays -outside the entity-neutral shell; future Player selection should follow its own -requirements. The small `_summary_cards.html` partial renders route-provided +outside the entity-neutral shell; Player selection uses its own controls. +The small `_summary_cards.html` partial renders route-provided cards and preserves Comparison's distinct section class and accessible label. The brand links to bare `/`. On Team pages, Teams links to the selection-aware @@ -32,6 +31,25 @@ Hits URL. Metric links preserve `team_id`, `season`, and `window`, including the existing requested-versus-resolved distinction in terminal states. Generic validation and schema errors do not require Team context. -Teams uses `aria-current="location"`; only the current metric uses +The current domain uses `aria-current="location"`; only the current Team metric uses `aria-current="page"`. Groups stack on mobile and links wrap, without navigation JavaScript or tab semantics. Selectors and chart-local scrolling are unchanged. + +## Player directory + +`/players` is the Player-domain landing and directory page. Available seasons +and selection membership come from persisted `player_seasons`, independently of +hitting data. With no requested season it selects the newest stored catalog +season. Explicit unavailable seasons and players outside the selected catalog +return a useful 404; an empty catalog or no-match search has a useful 200 state. + +GET parameters `season`, `q`, and `player_id` make searches and selections +shareable. Name search reuses the catalog's case/accent folding, shows at most +50 alphabetical matches, and renders no result list until a name is entered. +Submitting the season/search form clears `player_id`. Selection confirms only +the stored name, primary position, and season membership; identity fields are +not historical season attributes. Browser requests read the database only. + +Player metrics remain intentionally absent. Player charts and additional routes +will be added only when a real metric is chosen. Team URLs, metric navigation, +and Team query semantics remain unchanged. diff --git a/tests/test_repositories_player_catalog.py b/tests/test_repositories_player_catalog.py index 9105b3c..9047aac 100644 --- a/tests/test_repositories_player_catalog.py +++ b/tests/test_repositories_player_catalog.py @@ -4,10 +4,14 @@ from app.database.repositories import ( list_player_catalog, + list_player_catalog_seasons, + upsert_player, upsert_player_catalog_entry, + upsert_player_season_hitting, ) from app.schemas.ingestion import PlayerPersistenceOutcome from app.schemas.players import PlayerSeasonCatalogEntry +from tests.test_repositories_players import make_hitting, make_identity def entry( @@ -94,3 +98,31 @@ def test_catalog_ordering_ignores_case_and_accents(migrated_session: Session) -> "Ángel Martínez", "Zack Wheeler", ] + + +def test_catalog_seasons_are_unique_newest_first_without_hitting( + migrated_session: Session, +) -> None: + assert list_player_catalog_seasons(migrated_session) == [] + for catalog_entry in [ + entry(1, season=1999), + entry(2, season=2003), + entry(3, season=2003), + entry(1, season=2001), + ]: + upsert_player_catalog_entry(migrated_session, entry=catalog_entry) + migrated_session.flush() + migrated_session.commit() + assert list_player_catalog_seasons(migrated_session) == [2003, 2001, 1999] + + +def test_hitting_rows_and_global_identities_do_not_supply_catalog_seasons( + migrated_session: Session, +) -> None: + upsert_player(migrated_session, identity=make_identity()) + upsert_player_season_hitting(migrated_session, hitting=make_hitting()) + migrated_session.commit() + assert list_player_catalog_seasons(migrated_session) == [] + upsert_player_catalog_entry(migrated_session, entry=entry(season=2001)) + migrated_session.commit() + assert list_player_catalog_seasons(migrated_session) == [2001] diff --git a/tests/test_web_comparison.py b/tests/test_web_comparison.py index 77bb37a..d465a0d 100644 --- a/tests/test_web_comparison.py +++ b/tests/test_web_comparison.py @@ -351,9 +351,11 @@ def test_comparison_page_does_not_add_dead_mockup_controls( client: TestClient, seed: SeedFn, record_coverage: CoverageFn ) -> None: seed_exact_comparison(seed, record_coverage) - body = visible_text(comparison_response(client).text) + response = comparison_response(client) + body = visible_text(response.text) - for dead_control in ("7D", "30D", "60D", "Export", "Players"): + assert 'href="/players">Players' in response.text + for dead_control in ("7D", "30D", "60D", "Export"): assert dead_control not in body diff --git a/tests/test_web_page_scaffolding.py b/tests/test_web_page_scaffolding.py index a888c3d..6a0b5ea 100644 --- a/tests/test_web_page_scaffolding.py +++ b/tests/test_web_page_scaffolding.py @@ -174,7 +174,8 @@ def assert_navigation(body: str, *, current: AnalyticsPage, query: str) -> None: navigation = NavigationParser(body) assert list(navigation.landmarks) == ["Primary", "Team analytics"] assert navigation.landmarks["Primary"] == [ - {"label": "Teams", "href": f"/{suffix}", "current": "location"} + {"label": "Teams", "href": f"/{suffix}", "current": "location"}, + {"label": "Players", "href": "/players", "current": ""}, ] order = ( "/", diff --git a/tests/test_web_players.py b/tests/test_web_players.py new file mode 100644 index 0000000..b8a2634 --- /dev/null +++ b/tests/test_web_players.py @@ -0,0 +1,305 @@ +"""Player discovery stays local, season-scoped, accessible, and stats-free.""" + +from collections.abc import Iterator +from html import unescape +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +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_player, + upsert_player_catalog_entry, + upsert_player_season_hitting, +) +from app.main import create_app +from app.web.dependencies import get_db_session +from tests.test_repositories_player_catalog import entry +from tests.test_repositories_players import make_hitting, make_identity +from tests.test_web_page_scaffolding import NavigationParser + + +@pytest.fixture(autouse=True) +def forbid_mlb(monkeypatch: pytest.MonkeyPatch) -> None: + """Fail every Player browser state immediately on network/ingestion calls.""" + + def fail(*args: object, **kwargs: object) -> None: + raise AssertionError("Player browser requests must read the database only") + + monkeypatch.setattr(requests.Session, "request", fail) + for target in ( + "mlbstatsapi.Mlb.__init__", + "mlbstatsapi.AsyncMlb.__init__", + "app.services.players.discover_mlb_players", + "app.services.players.get_player_identity", + "app.services.players.get_player_season_hitting", + "app.services.player_catalog_ingestion.ingest_player_catalog", + "app.services.player_season_ingestion.ingest_player_season", + "app.services.team_game_logs.get_team_game_batting_lines", + "app.services.league_teams.discover_mlb_teams", + "app.services.league_season_ingestion.ingest_league_season", + "app.database.repositories.get_player_season_hitting", + ): + monkeypatch.setattr(target, fail) + + +@pytest.fixture +def client(migrated_session: Session) -> Iterator[TestClient]: + app = create_app() + + def override_session() -> Iterator[Session]: + yield migrated_session + + app.dependency_overrides[get_db_session] = override_session + yield TestClient(app) + + +@pytest.fixture +def catalog(migrated_session: Session) -> None: + # Deliberately old seasons guard against a calendar-based default. + for player in ( + entry(677594, "Julio Rodríguez", "CF", 2003), + entry(2, "Ángel Martínez", "2B", 2003), + entry(3, "Other Season Rodríguez", "P", 1999), + ): + upsert_player_catalog_entry(migrated_session, entry=player) + upsert_player(migrated_session, identity=make_identity(player_id=99)) + migrated_session.commit() + + +def assert_player_navigation(body: str) -> None: + nav = NavigationParser(body) + assert nav.landmarks == { + "Primary": [ + {"label": "Teams", "href": "/", "current": ""}, + {"label": "Players", "href": "/players", "current": "location"}, + ] + } + assert nav.main_ids == ["main-content"] + assert nav.skip_target == "#main-content" + assert not nav.tabs + + +def test_empty_catalog_has_real_import_guidance(client: TestClient) -> None: + response = client.get("/players") + assert response.status_code == 200 + assert "No Player catalog is stored locally" in response.text + assert ( + "poetry run python scripts/import_player_catalog.py --season <season>" + in response.text + ) + assert "player-results__list" not in response.text + assert "Selected Player" not in response.text + assert_player_navigation(response.text) + + +@pytest.mark.usefixtures("catalog") +def test_default_season_comes_from_catalog_and_does_not_dump_names( + client: TestClient, +) -> None: + response = client.get("/players") + assert response.status_code == 200 + assert '' in response.text + assert "Find a player" in response.text + assert "Julio Rodríguez" not in response.text + assert "player-results__list" not in response.text + assert_player_navigation(response.text) + + +@pytest.mark.usefixtures("catalog") +def test_explicit_season_and_scoped_case_accent_insensitive_search( + client: TestClient, +) -> None: + response = client.get("/players?season=1999&q=RODRIGUEZ") + assert response.status_code == 200 + assert '' in response.text + assert "Other Season Rodríguez" in response.text + assert "Julio Rodríguez" not in response.text + response = client.get("/players?season=2003&q=rOdRiGuEz") + assert "Julio Rodríguez" in response.text + assert "Other Season Rodríguez" not in response.text + assert "Ángel Martínez" not in response.text + assert "Ángel Martínez" in client.get("/players?q=ANGEL").text + + +@pytest.mark.usefixtures("catalog") +def test_no_match_is_useful_200(client: TestClient) -> None: + response = client.get("/players?q=nomatch") + assert response.status_code == 200 + assert "No players match" in response.text + assert "Try another name or season" in response.text + assert "player-results__list" not in response.text + + +@pytest.mark.parametrize("query", ["", "&q=julio&player_id=677594"]) +@pytest.mark.usefixtures("catalog") +def test_unavailable_season_preserved_with_stored_choices( + client: TestClient, query: str +) -> None: + response = client.get(f"/players?season=1980{query}") + assert response.status_code == 404 + assert "No Player catalog is stored locally for 1980" in response.text + assert "Stored seasons: 2003, 1999." in response.text + assert '' in response.text + assert "Selected Player" not in response.text + assert_player_navigation(response.text) + + +def test_explicit_missing_season_in_empty_catalog(client: TestClient) -> None: + response = client.get("/players?season=1980") + assert response.status_code == 404 + assert "No Player catalog is stored locally for 1980" in response.text + assert "scripts/import_player_catalog.py --season 1980" in response.text + # One empty-catalog notice, not a second contradictory selection notice. + assert "That Player selection is not stored locally" not in response.text + assert response.text.count('class="card notice"') == 1 + + +def test_selection_in_empty_catalog_does_not_invent_a_season( + client: TestClient, +) -> None: + response = client.get("/players?player_id=677594") + assert response.status_code == 404 + assert "Player 677594 is not in the locally stored catalog." in response.text + assert "None" not in response.text + assert "No Player catalog is stored locally" in response.text + assert "Selected Player" not in response.text + + +@pytest.mark.usefixtures("catalog") +def test_result_link_is_shareable_and_selection_is_identity_only( + client: TestClient, migrated_session: Session +) -> None: + upsert_player_season_hitting(migrated_session, hitting=make_hitting(season=2003)) + migrated_session.commit() + response = client.get("/players?season=2003&q=julio&team_id=136&window=15") + link = "/players?season=2003&q=julio&player_id=677594" + assert f'href="{link}"' in response.text + selected = client.get(unescape(link)) + assert selected.status_code == 200 + assert "Selected Player" in selected.text + assert "You selected Julio Rodríguez" in selected.text + assert "2003 catalog" in selected.text + assert "Primary position: CF" in selected.text + assert "— Selected" in selected.text + assert selected.text.count('aria-current="true"') == 1 + assert 'aria-current="true">Julio Rodríguez' in selected.text + assert_player_navigation(selected.text) + for absent in ( + "Batting Average", + "Home Runs", + "Plate Appearances", + "OPS", + "Strikeouts", + "Walks", + "Hits", + "plotly", + 'name="team_id"', + 'name="window"', + 'name="player_id"', + "/players/hitting", + "/players/compare", + ): + assert absent not in selected.text + # Search form submits only season and q, intentionally clearing selection. + assert 'method="get" action="/players"' in selected.text + assert '' in selected.text + assert '' in selected.text + assert parse_qs(urlsplit(unescape(link)).query) == { + "season": ["2003"], + "q": ["julio"], + "player_id": ["677594"], + } + + +@pytest.mark.parametrize("player_id", [3, 99, 99999]) +@pytest.mark.usefixtures("catalog") +def test_selection_requires_season_membership( + client: TestClient, player_id: int +) -> None: + response = client.get(f"/players?season=2003&player_id={player_id}") + assert response.status_code == 404 + assert ( + f"Player {player_id} is not in the locally stored catalog for 2003" + in response.text + ) + assert "Selected Player" not in response.text + assert_player_navigation(response.text) + + +@pytest.mark.usefixtures("catalog") +def test_selection_is_independent_of_search(client: TestClient) -> None: + for query in ("", "&q=nomatch"): + response = client.get(f"/players?player_id=677594{query}") + assert response.status_code == 200 + assert "You selected Julio Rodríguez" in response.text + + +def test_result_limit_and_selection_beyond_limit( + client: TestClient, migrated_session: Session +) -> None: + for number in range(55): + upsert_player_catalog_entry( + migrated_session, + entry=entry(number + 1, f"Test Player {number:02}", "P", 2001), + ) + migrated_session.commit() + response = client.get("/players?season=2001&q=test&player_id=55") + assert response.status_code == 200 + assert "55 matching players" in response.text + assert ( + "Showing the first 50 results alphabetically. Refine your search" + in response.text + ) + assert response.text.count('href="/players?') == 50 + assert "Test Player 49" in response.text + assert "Test Player 50" not in response.text + assert "You selected Test Player 54" in response.text + + +@pytest.mark.usefixtures("catalog") +def test_query_encoding_and_html_escaping(client: TestClient) -> None: + query = '">&season=1999' + response = client.get("/players", params={"q": query}) + assert response.status_code == 200 + assert "" not in response.text + assert "<script>" in response.text + assert '