diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..200bce0 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + branches: + - main + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install ruff + run: pip install ruff==0.8.6 + + - name: Check formatting + run: ruff format --check . + + - name: Check linting + run: ruff check . + + test: + name: Test + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Run tests + run: pytest tests/ -v + + build-push: + name: Build & Push + runs-on: ubuntu-latest + needs: test + # Only build on pushes to main or version tags — not on PRs + if: github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ gitea.server_url && '' }}${{ vars.GITEA_REGISTRY }}/nhlscoreboard + tags: | + type=semver,pattern={{version}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Log in to Gitea registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.GITEA_REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.GITEA_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/docker-deploy.yml b/.github/workflows/docker-deploy.yml deleted file mode 100644 index 826624f..0000000 --- a/.github/workflows/docker-deploy.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Docker Build and Push - -on: - release: - types: [published] - -jobs: - build-and-push: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - uses: mr-smithers-excellent/docker-build-push@v6.3 - name: Build & push Docker image - with: - image: joshnotwright/nhlscoreboard - tags: ${{ github.event.release.tag_name }}, latest - registry: docker.io - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - diff --git a/Dockerfile b/Dockerfile index 8b7f367..0f79154 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,25 @@ -# Use an official Python runtime as a parent image -FROM python:3.9-slim +FROM python:3.13-slim -# Set environment variables -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + DATA_DIR=/app/app/data -# Set the working directory in the container WORKDIR /app -# Copy the current directory contents into the container at /app -COPY . /app - -# Install any needed dependencies specified in requirements.txt +COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Create the directory for scoreboard data -RUN mkdir -p app/data +COPY . . + +RUN mkdir -p $DATA_DIR \ + && adduser --disabled-password --gecos "" appuser \ + && chown -R appuser:appuser /app + +USER appuser -# Expose the Flask port EXPOSE 2897 -# Run the Flask application +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:2897/')" || exit 1 + CMD ["python", "run.py"] diff --git a/app/__init__.py b/app/__init__.py index 96c8ef5..665cde7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,13 @@ +import logging from flask import Flask +from app.config import LOG_LEVEL + +logging.basicConfig( + level=getattr(logging, LOG_LEVEL.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) app = Flask(__name__) -from app import routes +from app import routes # noqa: E402, F401 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..f9c5799 --- /dev/null +++ b/app/config.py @@ -0,0 +1,8 @@ +import os + +DATA_DIR = os.environ.get("DATA_DIR", "app/data") +PORT = int(os.environ.get("PORT", 2897)) +LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") + +SCOREBOARD_DATA_FILE = os.path.join(DATA_DIR, "scoreboard_data.json") +DB_PATH = os.path.join(DATA_DIR, "nhl_standings.db") diff --git a/app/routes.py b/app/routes.py index dc39732..da876be 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,32 +1,51 @@ -from app import app -from flask import render_template, jsonify -from app.scoreboard.process_data import extract_game_info import json -SCOREBOARD_DATA_FILE = 'app/data/scoreboard_data.json' +from flask import render_template, jsonify -@app.route('/') +from app import app +from app.config import SCOREBOARD_DATA_FILE +from app.scoreboard.process_data import extract_game_info + + +@app.route("/") def index(): - return render_template('index.html') - -@app.route('/scoreboard') + return render_template("index.html") + + +@app.route("/scoreboard") def get_scoreboard(): try: - with open(SCOREBOARD_DATA_FILE, 'r') as json_file: + with open(SCOREBOARD_DATA_FILE, "r") as json_file: scoreboard_data = json.load(json_file) except FileNotFoundError: return jsonify({"error": "Failed to retrieve scoreboard data. File not found."}) except json.JSONDecodeError: - return jsonify({"error": "Failed to retrieve scoreboard data. Invalid JSON format."}) + return jsonify( + {"error": "Failed to retrieve scoreboard data. Invalid JSON format."} + ) if scoreboard_data: - live_games = [game for game in extract_game_info(scoreboard_data) if game["Game State"] == "LIVE"] - pre_games = [game for game in extract_game_info(scoreboard_data) if game["Game State"] == "PRE"] - final_games = [game for game in extract_game_info(scoreboard_data) if game["Game State"] == "FINAL"] - return jsonify({ - "live_games": live_games, - "pre_games": pre_games, - "final_games": final_games - }) + live_games = [ + game + for game in extract_game_info(scoreboard_data) + if game["Game State"] == "LIVE" + ] + pre_games = [ + game + for game in extract_game_info(scoreboard_data) + if game["Game State"] == "PRE" + ] + final_games = [ + game + for game in extract_game_info(scoreboard_data) + if game["Game State"] == "FINAL" + ] + return jsonify( + { + "live_games": live_games, + "pre_games": pre_games, + "final_games": final_games, + } + ) else: return jsonify({"error": "Failed to retrieve scoreboard data"}) diff --git a/app/scoreboard/get_data.py b/app/scoreboard/get_data.py index 41fe7c6..702714c 100644 --- a/app/scoreboard/get_data.py +++ b/app/scoreboard/get_data.py @@ -1,34 +1,40 @@ -import requests -from datetime import datetime import json +import logging +from datetime import datetime +from zoneinfo import ZoneInfo + +import requests + +from app.config import SCOREBOARD_DATA_FILE + +logger = logging.getLogger(__name__) + +EASTERN = ZoneInfo("America/New_York") -SCOREBOARD_DATA_FILE = 'app/data/scoreboard_data.json' def get_scoreboard_data(): - now = datetime.now() - start_time_evening = now.replace(hour=19, minute=00, second=0, microsecond=0) # 7:00 PM EST - end_time_evening = now.replace(hour=3, minute=00, second=0, microsecond=0) # 3:00 AM EST + now = datetime.now(EASTERN) + start_time_evening = now.replace(hour=19, minute=0, second=0, microsecond=0) + end_time_morning = now.replace(hour=3, minute=0, second=0, microsecond=0) - if now >= start_time_evening or now < end_time_evening: - # Use now URL + if now >= start_time_evening or now < end_time_morning: nhle_api_url = "https://api-web.nhle.com/v1/score/now" - else: - # Use current data URL nhle_api_url = f"https://api-web.nhle.com/v1/score/{now.strftime('%Y-%m-%d')}" - - response = requests.get(nhle_api_url) - if response.status_code == 200: - return response.json() - else: - print("Error:", response.status_code) -# Store scoreboard data locally + try: + response = requests.get(nhle_api_url, timeout=10) + response.raise_for_status() + return response.json() + except requests.RequestException as e: + logger.error("Failed to fetch scoreboard data from %s: %s", nhle_api_url, e) + return None + + def store_scoreboard_data(): - scoreboard_data = get_scoreboard_data() + scoreboard_data = get_scoreboard_data() if scoreboard_data: - with open(SCOREBOARD_DATA_FILE, 'w') as json_file: + with open(SCOREBOARD_DATA_FILE, "w") as json_file: json.dump(scoreboard_data, json_file) - return scoreboard_data - else: - return None \ No newline at end of file + return scoreboard_data + return None diff --git a/app/scoreboard/process_data.py b/app/scoreboard/process_data.py index 06e20a7..a86bc29 100644 --- a/app/scoreboard/process_data.py +++ b/app/scoreboard/process_data.py @@ -1,6 +1,12 @@ +import logging import sqlite3 from datetime import datetime, timedelta +from app.config import DB_PATH + +logger = logging.getLogger(__name__) + + def process_record(record): if record == "N/A": return "N/A" @@ -9,6 +15,7 @@ def process_record(record): formatted_parts = [part.zfill(2) for part in parts] return "-".join(formatted_parts) + def extract_game_info(scoreboard_data): if not scoreboard_data: return [] @@ -16,36 +23,60 @@ def extract_game_info(scoreboard_data): extracted_info = [] for game in scoreboard_data.get("games", []): game_state = convert_game_state(game["gameState"]) - extracted_info.append({ - "Home Team": game["homeTeam"]["name"]["default"], - "Home Score": game["homeTeam"]["score"] if game_state != "PRE" else "N/A", - "Away Team": game["awayTeam"]["name"]["default"], - "Away Score": game["awayTeam"]["score"] if game_state != "PRE" else "N/A", - "Home Logo": game["homeTeam"]["logo"], - "Away Logo": game["awayTeam"]["logo"], - "Game State": game_state, - "Period": process_period(game), - "Time Remaining": process_time_remaining(game), - "Time Running": game["clock"]["running"] if game_state == "LIVE" else "N/A", - "Intermission": game["clock"]["inIntermission"] if game_state == "LIVE" else "N/A", - "Priority": calculate_game_priority(game), - "Start Time": process_start_time(game), - "Home Record": process_record(game["homeTeam"]["record"]) if game["gameState"] in ["PRE", "FUT"] else "N/A", - "Away Record": process_record(game["awayTeam"]["record"]) if game["gameState"] in ["PRE", "FUT"] else "N/A", - "Home Shots": game["homeTeam"]["sog"] if game["gameState"] not in ["PRE", "FUT"] else 0, - "Away Shots": game["awayTeam"]["sog"] if game["gameState"] not in ["PRE", "FUT"] else 0, - "Home Power Play": get_power_play_info(game, game["homeTeam"]["name"]["default"]), - "Away Power Play": get_power_play_info(game, game["awayTeam"]["name"]["default"]), - "Last Period Type": get_game_outcome(game, game_state) - }) + extracted_info.append( + { + "Home Team": game["homeTeam"]["name"]["default"], + "Home Score": game["homeTeam"]["score"] + if game_state != "PRE" + else "N/A", + "Away Team": game["awayTeam"]["name"]["default"], + "Away Score": game["awayTeam"]["score"] + if game_state != "PRE" + else "N/A", + "Home Logo": game["homeTeam"]["logo"], + "Away Logo": game["awayTeam"]["logo"], + "Game State": game_state, + "Period": process_period(game), + "Time Remaining": process_time_remaining(game), + "Time Running": game["clock"]["running"] + if game_state == "LIVE" + else "N/A", + "Intermission": game["clock"]["inIntermission"] + if game_state == "LIVE" + else "N/A", + "Priority": calculate_game_priority(game), + "Start Time": process_start_time(game), + "Home Record": process_record(game["homeTeam"]["record"]) + if game["gameState"] in ["PRE", "FUT"] + else "N/A", + "Away Record": process_record(game["awayTeam"]["record"]) + if game["gameState"] in ["PRE", "FUT"] + else "N/A", + "Home Shots": game["homeTeam"]["sog"] + if game["gameState"] not in ["PRE", "FUT"] + else 0, + "Away Shots": game["awayTeam"]["sog"] + if game["gameState"] not in ["PRE", "FUT"] + else 0, + "Home Power Play": get_power_play_info( + game, game["homeTeam"]["name"]["default"] + ), + "Away Power Play": get_power_play_info( + game, game["awayTeam"]["name"]["default"] + ), + "Last Period Type": get_game_outcome(game, game_state), + } + ) # Sort games based on priority return sorted(extracted_info, key=lambda x: x["Priority"], reverse=True) + def convert_game_state(game_state): state_mapping = {"OFF": "FINAL", "CRIT": "LIVE", "FUT": "PRE"} return state_mapping.get(game_state, game_state) + def process_period(game): if game["gameState"] in ["PRE", "FUT"]: return 0 @@ -54,6 +85,7 @@ def process_period(game): else: return game["periodDescriptor"]["number"] + def process_time_remaining(game): if game["gameState"] in ["PRE", "FUT"]: return "20:00" @@ -63,17 +95,16 @@ def process_time_remaining(game): time_remaining = game["clock"]["timeRemaining"] return "END" if time_remaining == "00:00" else time_remaining + def process_start_time(game): if game["gameState"] in ["PRE", "FUT"]: utc_time = game["startTimeUTC"] est_time = utc_to_est_time(utc_time) - # Check if the hour starts with a zero - if est_time.startswith("0"): - est_time = est_time[1:] # Drop the leading zero - return est_time + return est_time.lstrip("0") else: return "N/A" + def get_power_play_info(game, team_name): if "situation" in game and "situationDescriptions" in game["situation"]: for situation in game["situation"]["situationDescriptions"]: @@ -83,14 +114,16 @@ def get_power_play_info(game, team_name): return f"PP {game['situation']['timeRemaining']}" return "" + def get_game_outcome(game, game_state): return game["gameOutcome"]["lastPeriodType"] if game_state == "FINAL" else "N/A" + def calculate_game_priority(game): # Return 0 if game is in certain states if game["gameState"] in ["FINAL", "OFF", "PRE", "FUT"]: return 0 - + # Get period, time remaining, scores, and other relevant data period = game.get("periodDescriptor", {}).get("number", 0) time_remaining = game.get("clock", {}).get("secondsRemaining", 0) @@ -104,8 +137,14 @@ def calculate_game_priority(game): away_team_standings = get_team_standings(game["awayTeam"]["name"]["default"]) # Calculate total values of leagueSequence + leagueL10Sequence for each team - home_total = home_team_standings["league_sequence"] + home_team_standings["league_l10_sequence"] - away_total = away_team_standings["league_sequence"] + away_team_standings["league_l10_sequence"] + home_total = ( + home_team_standings["league_sequence"] + + home_team_standings["league_l10_sequence"] + ) + away_total = ( + away_team_standings["league_sequence"] + + away_team_standings["league_l10_sequence"] + ) # Calculate the matchup adjustment factor matchup_multiplier = {5: 1, 4: 1, 3: 1.50, 2: 1.65, 1: 2}.get(period) @@ -123,11 +162,11 @@ def calculate_game_priority(game): score_differential_adjustment += 350 elif score_difference > 1: score_differential_adjustment += 100 - + if period == 3 and time_remaining <= 300: score_differential_adjustment = score_differential_adjustment * 2 - - base_priority -= score_differential_adjustment + + base_priority -= score_differential_adjustment # Adjust base priority based on certain conditions if period == 3 and time_remaining <= 720: @@ -142,40 +181,51 @@ def calculate_game_priority(game): elif score_difference == 1: base_priority += 30 - # Calculate time priority time_multiplier = {4: 2, 3: 2, 2: 1.5}.get(period, 0.75) - time_priority = ((1200 - time_remaining) / 20) * time_multiplier - print(base_priority) - print(time_priority) - print(matchup_adjustment) - print(score_total) + logger.debug( + "priority components — base: %s, time: %s, matchup: %s, score_total: %s", + base_priority, + time_priority, + matchup_adjustment, + score_total, + ) # Calculate the final priority - final_priority = int(base_priority + time_priority - matchup_adjustment + score_total) + final_priority = int( + base_priority + time_priority - matchup_adjustment + score_total + ) # Pushes the games that are in intermission to the bottom, but retains their sort if game["clock"]["inIntermission"]: - return (-2000 - time_remaining) + return -2000 - time_remaining return final_priority + def get_team_standings(team_name): - conn = sqlite3.connect("app/data/nhl_standings.db") + conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() - cursor.execute(""" - SELECT league_sequence, league_l10_sequence - FROM standings + cursor.execute( + """ + SELECT league_sequence, league_l10_sequence + FROM standings WHERE team_common_name = ? - """, (team_name,)) + """, + (team_name,), + ) result = cursor.fetchone() conn.close() - return {"league_sequence": result[0] if result else 0, "league_l10_sequence": result[1] if result else 0} + return { + "league_sequence": result[0] if result else 0, + "league_l10_sequence": result[1] if result else 0, + } + def utc_to_est_time(utc_time): utc_datetime = datetime.strptime(utc_time, "%Y-%m-%dT%H:%M:%SZ") est_offset = timedelta(hours=-4) est_datetime = utc_datetime + est_offset - return est_datetime.strftime("%#I:%M %p") + return est_datetime.strftime("%I:%M %p") diff --git a/app/scoreboard/tasks.py b/app/scoreboard/tasks.py index 4fae985..566c947 100644 --- a/app/scoreboard/tasks.py +++ b/app/scoreboard/tasks.py @@ -1,13 +1,18 @@ -import schedule +import logging import time -from app.scoreboard.update_nhl_standings_db import update_nhl_standings + +import schedule + from app.scoreboard.get_data import store_scoreboard_data +from app.scoreboard.update_nhl_standings_db import update_nhl_standings + +logger = logging.getLogger(__name__) + def schedule_tasks(): schedule.every(600).seconds.do(update_nhl_standings) schedule.every(10).seconds.do(store_scoreboard_data) + logger.info("Background scheduler started") while True: schedule.run_pending() time.sleep(1) - - diff --git a/app/scoreboard/update_nhl_standings_db.py b/app/scoreboard/update_nhl_standings_db.py index c1e4455..b0d18a6 100644 --- a/app/scoreboard/update_nhl_standings_db.py +++ b/app/scoreboard/update_nhl_standings_db.py @@ -1,6 +1,13 @@ +import logging import sqlite3 + import requests +from app.config import DB_PATH + +logger = logging.getLogger(__name__) + + def create_standings_table(conn): cursor = conn.cursor() cursor.execute(""" @@ -12,54 +19,55 @@ def create_standings_table(conn): """) conn.commit() + def truncate_standings_table(conn): cursor = conn.cursor() cursor.execute("DELETE FROM standings") conn.commit() + def insert_standings_info(conn, standings_info): cursor = conn.cursor() for team in standings_info: - cursor.execute(""" + cursor.execute( + """ INSERT INTO standings (team_common_name, league_sequence, league_l10_sequence) VALUES (?, ?, ?) - """, (team["team_common_name"], team["league_sequence"], team["league_l10_sequence"])) + """, + ( + team["team_common_name"], + team["league_sequence"], + team["league_l10_sequence"], + ), + ) conn.commit() + def extract_standings_info(): url = "https://api-web.nhle.com/v1/standings/now" - response = requests.get(url) - if response.status_code == 200: + try: + response = requests.get(url, timeout=10) + response.raise_for_status() standings_data = response.json() standings_info = [] for team in standings_data.get("standings", []): team_info = { "team_common_name": team["teamCommonName"]["default"], "league_sequence": team["leagueSequence"], - "league_l10_sequence": team["leagueL10Sequence"] + "league_l10_sequence": team["leagueL10Sequence"], } standings_info.append(team_info) return standings_info - else: - print("Error:", response.status_code) + except requests.RequestException as e: + logger.error("Failed to fetch standings: %s", e) return None + def update_nhl_standings(): - # Connect to SQLite database - conn = sqlite3.connect("app/data/nhl_standings.db") - - # Create standings table if it doesn't exist + conn = sqlite3.connect(DB_PATH) create_standings_table(conn) - - # Truncate standings table before inserting new data truncate_standings_table(conn) - - # Extract standings info standings_info = extract_standings_info() - - # Insert standings info into the database if standings_info: insert_standings_info(conn, standings_info) - - # Close database connection conn.close() diff --git a/app/templates/index.html b/app/templates/index.html index 9dbb0c3..f0b7068 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -3,7 +3,7 @@ NHL Scoreboard - +
diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ed96485 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + nhl-scoreboard: + build: . + ports: + - "2897:2897" + volumes: + - ./data:/app/app/data + environment: + - DATA_DIR=/app/app/data + - LOG_LEVEL=INFO + restart: unless-stopped diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..34cbd13 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest==8.3.4 +pytest-mock==3.14.0 +ruff==0.8.6 diff --git a/requirements.txt b/requirements.txt index c35fa70..3eaa9ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -Flask==3.0.2 -Jinja2==3.1.3 -requests==2.31.0 -Werkzeug==3.0.1 -waitress==3.0.0 -schedule==1.2.1 +Flask==3.1.0 +Jinja2==3.1.4 +requests==2.32.3 +Werkzeug==3.1.3 +waitress==3.0.1 +schedule==1.2.2 diff --git a/run.py b/run.py index 5cc4ddf..9c8e54e 100644 --- a/run.py +++ b/run.py @@ -1,13 +1,13 @@ -from app import app -from waitress import serve import threading +from app import app +from app.config import PORT from app.scoreboard.tasks import schedule_tasks from app.scoreboard.get_data import store_scoreboard_data from app.scoreboard.update_nhl_standings_db import update_nhl_standings +from waitress import serve -if __name__ == '__main__': +if __name__ == "__main__": store_scoreboard_data() update_nhl_standings() - threading.Thread(target=schedule_tasks).start() - serve(app, host="0.0.0.0", port=2897) - + threading.Thread(target=schedule_tasks, daemon=True).start() + serve(app, host="0.0.0.0", port=PORT) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..1f5d8db Binary files /dev/null and b/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/__pycache__/conftest.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/conftest.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..a1aeb51 Binary files /dev/null and b/tests/__pycache__/conftest.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_process_data.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_process_data.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..fda81da Binary files /dev/null and b/tests/__pycache__/test_process_data.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_routes.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_routes.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..3947e89 Binary files /dev/null and b/tests/__pycache__/test_routes.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..12c0950 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,93 @@ +import json +import sqlite3 + +import pytest + + +def make_game( + game_state="LIVE", + home_name="Maple Leafs", + away_name="Bruins", + home_score=2, + away_score=1, + period=3, + seconds_remaining=300, + in_intermission=False, + start_time_utc="2024-04-10T23:00:00Z", + home_record="40-25-10", + away_record="38-27-09", +): + clock = { + "timeRemaining": f"{seconds_remaining // 60:02d}:{seconds_remaining % 60:02d}", + "secondsRemaining": seconds_remaining, + "running": game_state == "LIVE", + "inIntermission": in_intermission, + } + return { + "gameState": game_state, + "startTimeUTC": start_time_utc, + "periodDescriptor": {"number": period}, + "clock": clock, + "homeTeam": { + "name": {"default": home_name}, + "score": home_score, + "sog": 15, + "logo": "https://example.com/home.png", + "record": home_record, + }, + "awayTeam": { + "name": {"default": away_name}, + "score": away_score, + "sog": 12, + "logo": "https://example.com/away.png", + "record": away_record, + }, + "gameOutcome": {"lastPeriodType": "REG"}, + } + + +LIVE_GAME = make_game() +PRE_GAME = make_game( + game_state="FUT", home_score=0, away_score=0, period=0, seconds_remaining=1200 +) +FINAL_GAME = make_game(game_state="OFF", period=3, seconds_remaining=0) + +SAMPLE_SCOREBOARD = {"games": [LIVE_GAME, PRE_GAME, FINAL_GAME]} + + +@pytest.fixture() +def sample_scoreboard(): + return SAMPLE_SCOREBOARD + + +@pytest.fixture() +def flask_client(tmp_path, monkeypatch): + data_dir = tmp_path / "data" + data_dir.mkdir() + + # Write sample scoreboard JSON + scoreboard_file = data_dir / "scoreboard_data.json" + scoreboard_file.write_text(json.dumps(SAMPLE_SCOREBOARD)) + + # Create minimal SQLite DB so get_team_standings doesn't crash + db_path = data_dir / "nhl_standings.db" + conn = sqlite3.connect(str(db_path)) + conn.execute( + "CREATE TABLE standings " + "(team_common_name TEXT, league_sequence INTEGER, league_l10_sequence INTEGER)" + ) + conn.commit() + conn.close() + + # Patch module-level path constants so no reloads are needed + import app.routes as routes + import app.scoreboard.process_data as process_data + + monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", str(scoreboard_file)) + monkeypatch.setattr(process_data, "DB_PATH", str(db_path)) + + from app import app as flask_app + + flask_app.config["TESTING"] = True + with flask_app.test_client() as client: + yield client diff --git a/tests/test_process_data.py b/tests/test_process_data.py new file mode 100644 index 0000000..f9df1af --- /dev/null +++ b/tests/test_process_data.py @@ -0,0 +1,103 @@ +from tests.conftest import make_game +from app.scoreboard.process_data import ( + convert_game_state, + get_game_outcome, + process_period, + process_record, + process_start_time, + process_time_remaining, + utc_to_est_time, +) + + +class TestConvertGameState: + def test_off_maps_to_final(self): + assert convert_game_state("OFF") == "FINAL" + + def test_crit_maps_to_live(self): + assert convert_game_state("CRIT") == "LIVE" + + def test_fut_maps_to_pre(self): + assert convert_game_state("FUT") == "PRE" + + def test_unknown_state_passes_through(self): + assert convert_game_state("LIVE") == "LIVE" + + +class TestProcessRecord: + def test_na_returns_na(self): + assert process_record("N/A") == "N/A" + + def test_pads_single_digit_parts(self): + assert process_record("5-3-1") == "05-03-01" + + def test_already_padded_unchanged(self): + assert process_record("40-25-10") == "40-25-10" + + +class TestProcessPeriod: + def test_pre_game_returns_zero(self): + game = make_game(game_state="PRE") + assert process_period(game) == 0 + + def test_fut_game_returns_zero(self): + game = make_game(game_state="FUT") + assert process_period(game) == 0 + + def test_final_game_returns_na(self): + game = make_game(game_state="OFF") + assert process_period(game) == "N/A" + + def test_live_game_returns_period_number(self): + game = make_game(game_state="LIVE", period=2) + assert process_period(game) == 2 + + +class TestProcessTimeRemaining: + def test_pre_game_returns_2000(self): + game = make_game(game_state="FUT") + assert process_time_remaining(game) == "20:00" + + def test_final_game_returns_0000(self): + game = make_game(game_state="OFF") + assert process_time_remaining(game) == "00:00" + + def test_live_game_returns_clock(self): + game = make_game(game_state="LIVE", seconds_remaining=305) + assert process_time_remaining(game) == "05:05" + + def test_live_game_at_zero_returns_end(self): + game = make_game(game_state="LIVE", seconds_remaining=0) + assert process_time_remaining(game) == "END" + + +class TestProcessStartTime: + def test_pre_game_returns_est_time(self): + game = make_game(game_state="FUT", start_time_utc="2024-04-10T23:00:00Z") + result = process_start_time(game) + assert result == "7:00 PM" + + def test_pre_game_strips_leading_zero(self): + game = make_game(game_state="FUT", start_time_utc="2024-04-10T22:00:00Z") + result = process_start_time(game) + assert not result.startswith("0") + + def test_live_game_returns_na(self): + game = make_game(game_state="LIVE") + assert process_start_time(game) == "N/A" + + +class TestGetGameOutcome: + def test_final_game_returns_last_period_type(self): + game = make_game(game_state="OFF") + assert get_game_outcome(game, "FINAL") == "REG" + + def test_live_game_returns_na(self): + game = make_game(game_state="LIVE") + assert get_game_outcome(game, "LIVE") == "N/A" + + +class TestUtcToEstTime: + def test_converts_utc_to_est(self): + result = utc_to_est_time("2024-04-10T23:00:00Z") + assert result == "07:00 PM" diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..84a0d9e --- /dev/null +++ b/tests/test_routes.py @@ -0,0 +1,44 @@ +import json + + +class TestIndexRoute: + def test_returns_200(self, flask_client): + response = flask_client.get("/") + assert response.status_code == 200 + + def test_returns_html(self, flask_client): + response = flask_client.get("/") + assert b"NHL Scoreboard" in response.data + + +class TestScoreboardRoute: + def test_returns_200(self, flask_client): + response = flask_client.get("/scoreboard") + assert response.status_code == 200 + + def test_returns_json_with_expected_keys(self, flask_client): + response = flask_client.get("/scoreboard") + data = json.loads(response.data) + assert "live_games" in data + assert "pre_games" in data + assert "final_games" in data + + def test_live_games_have_required_fields(self, flask_client): + response = flask_client.get("/scoreboard") + data = json.loads(response.data) + if data["live_games"]: + game = data["live_games"][0] + assert "Home Team" in game + assert "Away Team" in game + assert "Home Score" in game + assert "Away Score" in game + assert "Game State" in game + assert game["Game State"] == "LIVE" + + def test_missing_file_returns_error(self, flask_client, monkeypatch): + import app.routes as routes + + monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", "/nonexistent/path.json") + response = flask_client.get("/scoreboard") + data = json.loads(response.data) + assert "error" in data