diff --git a/.gitignore b/.gitignore index 315b84f..5b6424a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +**.log .coverage **/__pycache__ htmlcov/ diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..b62abc0 --- /dev/null +++ b/app/config.py @@ -0,0 +1,26 @@ +""" +Configuration module for the MLB CLI application. +Centralizes all constants and environment-specific settings. +""" + +# TUI Settings +STATIC_WIDTH = 80 +INITIAL_HEIGHT = 36 + +# Redis Settings +REDIS_HOST = 'localhost' +REDIS_PORT = 6379 +REDIS_DB = 0 + +# Data Service Settings +LIVE_DATA_TTL = 300 # 5 minutes + +# Division name mapping +DIVISION_NAMES = { + 200: "AL West", + 201: "AL East", + 202: "AL Central", + 203: "NL West", + 204: "NL East", + 205: "NL Central" +} diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 0000000..aab1e92 --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,6 @@ +""" +Custom exceptions for the MLB CLI application. +""" + +class APIError(Exception): + """Raised when an error occurs while fetching data from the MLB API.""" diff --git a/app/logger.py b/app/logger.py new file mode 100644 index 0000000..42b2c6a --- /dev/null +++ b/app/logger.py @@ -0,0 +1,29 @@ +""" +Logging configuration for the MLB CLI application. +Provides a standard logger that writes to a local log file. +""" +import os +import logging +from logging.handlers import RotatingFileHandler + +# Ensure log directory exists +LOG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LOG_FILE = os.path.join(LOG_DIR, "mlb_cli.log") + +def get_logger(name): + """ + Configures and returns a logger instance. + """ + logger = logging.getLogger(name) + if not logger.handlers: + logger.setLevel(logging.DEBUG) + + # Rotating file handler (5MB per file, keeps 2 backups) + handler = RotatingFileHandler(LOG_FILE, maxBytes=5*1024*1024, backupCount=2) + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + + return logger diff --git a/app/models/base_data_source.py b/app/models/base_data_source.py new file mode 100644 index 0000000..72a5fb2 --- /dev/null +++ b/app/models/base_data_source.py @@ -0,0 +1,28 @@ +""" +Base module for data source abstractions. +Provides the interface for fetching MLB data. +""" +from abc import ABC, abstractmethod + + +class BaseDataSource(ABC): + """ + Abstract base class for all MLB data sources. + Defines the contract for fetching team info, schedules, and standings. + """ + + @abstractmethod + def fetch_teams(self): + """Fetches all MLB teams and returns a mapping of ID to abbreviation.""" + + @abstractmethod + def fetch_schedule(self, date_str): + """Fetches the MLB schedule for a specific date.""" + + @abstractmethod + def fetch_standings(self): + """Fetches current MLB standings.""" + + @abstractmethod + def fetch_wild_card(self, league_id): + """Fetches wild card standings for a league.""" diff --git a/app/models/cache_service.py b/app/models/cache_service.py index c933888..09bfa5d 100644 --- a/app/models/cache_service.py +++ b/app/models/cache_service.py @@ -5,10 +5,7 @@ import json import redis -# Default Redis configuration -REDIS_HOST = 'localhost' -REDIS_PORT = 6379 -REDIS_DB = 0 +from app.config import REDIS_HOST, REDIS_PORT, REDIS_DB try: # pylint: disable=invalid-name diff --git a/app/models/data_service.py b/app/models/data_service.py index 9d16ee9..ae42ec6 100644 --- a/app/models/data_service.py +++ b/app/models/data_service.py @@ -1,41 +1,25 @@ """ -Data service module for fetching MLB data via the statsapi library. -Provides functions for team abbreviations, schedules, and standings. +Data service module for fetching MLB data. +Delegates to a BaseDataSource implementation (defaulting to StatsApiDataSource). """ from datetime import datetime, timedelta -import statsapi +from app.config import LIVE_DATA_TTL from app.models.cache_service import get_cached_data, set_cached_data +from .statsapi_source import StatsApiDataSource # Global cache for team abbreviations TEAMS = {} -# TTL Policies (in seconds) -LIVE_DATA_TTL = 300 # 5 minutes - - -# Division name mapping -DIVISION_NAMES = { - 200: "AL West", - 201: "AL East", - 202: "AL Central", - 203: "NL West", - 204: "NL East", - 205: "NL Central" -} +# Default data source +_data_source = StatsApiDataSource() def fetch_teams(): """ Fetches all MLB teams and populates the global TEAMS cache with abbreviations. - Defaults to specific common teams if the API request fails. """ - try: - teams_data = statsapi.get('teams', {'sportId': 1})['teams'] - for t in teams_data: - TEAMS[t['id']] = t.get('abbreviation', t['name'][:3].upper()) - except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError): - # Fallback to some common ones if API fails - TEAMS.update({147: 'NYY', 110: 'BAL', 119: 'LAD'}) + TEAMS.clear() + TEAMS.update(_data_source.fetch_teams()) def get_team_abbr(team_id): @@ -63,7 +47,7 @@ def fetch_schedule(date_str): if cached is not None: return cached - data = statsapi.schedule(date=date_str) + data = _data_source.fetch_schedule(date_str) # Policy: Today has TTL, other dates don't ttl = LIVE_DATA_TTL if date_str == get_today_date() else None @@ -151,27 +135,20 @@ def fetch_wild_card(league_id): if cached is not None: return cached - try: - data = statsapi.get('standings', { - 'leagueId': league_id, - 'standingsTypes': 'wildCard' - }) - if not data or not data.get('records'): - return None - - record = data['records'][0] - teams = [_parse_team_record(tr, is_wild_card=True) for tr in record.get('teamRecords', [])] - - league_name = "AL" if league_id == 103 else "NL" - result = { - 'div_name': f"{league_name} Wild Card", - 'teams': teams[:7] - } - set_cached_data(cache_key, result, ttl=LIVE_DATA_TTL) - return result - except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError): + record = _data_source.fetch_wild_card(league_id) + if not record: return None + teams = [_parse_team_record(tr, is_wild_card=True) for tr in record.get('teamRecords', [])] + + league_name = "AL" if league_id == 103 else "NL" + result = { + 'div_name': f"{league_name} Wild Card", + 'teams': teams[:7] + } + set_cached_data(cache_key, result, ttl=LIVE_DATA_TTL) + return result + def fetch_standings(): """ @@ -186,35 +163,28 @@ def fetch_standings(): if cached is not None: return tuple(cached) - try: - data = statsapi.get('standings', {'leagueId': '103,104'}) - if not data or not data.get('records'): - return [], [], None, None - - div_map = {} - for record in data['records']: - div_id = record.get('division', {}).get('id') - if div_id: - div_map[div_id] = { - 'div_name': DIVISION_NAMES.get( - div_id, record.get('division', {}).get('name', 'Unknown') - ), - 'teams': [ - _parse_team_record(tr, is_wild_card=False) - for tr in record.get('teamRecords', []) - ] - } - - # AL IDs: East(201), Central(202), West(200) - # NL IDs: East(204), Central(205), West(203) - al_divs = [div_map.get(201), div_map.get(202), div_map.get(200)] - nl_divs = [div_map.get(204), div_map.get(205), div_map.get(203)] - - al_wc = fetch_wild_card(103) - nl_wc = fetch_wild_card(104) - - result = (al_divs, nl_divs, al_wc, nl_wc) - set_cached_data(cache_key, result, ttl=LIVE_DATA_TTL) - return result - except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError): + div_results = _data_source.fetch_standings() + if not div_results: return [], [], None, None + + div_map = {} + for div in div_results: + div_map[div['id']] = { + 'div_name': div['name'], + 'teams': [ + _parse_team_record(tr, is_wild_card=False) + for tr in div['teams'] + ] + } + + # AL IDs: East(201), Central(202), West(200) + # NL IDs: East(204), Central(205), West(203) + al_divs = [div_map.get(201), div_map.get(202), div_map.get(200)] + nl_divs = [div_map.get(204), div_map.get(205), div_map.get(203)] + + al_wc = fetch_wild_card(103) + nl_wc = fetch_wild_card(104) + + result = (al_divs, nl_divs, al_wc, nl_wc) + set_cached_data(cache_key, result, ttl=LIVE_DATA_TTL) + return result diff --git a/app/models/statsapi_source.py b/app/models/statsapi_source.py new file mode 100644 index 0000000..159a3c4 --- /dev/null +++ b/app/models/statsapi_source.py @@ -0,0 +1,71 @@ +""" +Implementation of BaseDataSource using the statsapi library. +""" +import statsapi +from app.config import DIVISION_NAMES +from app.logger import get_logger +from app.exceptions import APIError +from .base_data_source import BaseDataSource + +logger = get_logger(__name__) + + +class StatsApiDataSource(BaseDataSource): + """ + Data source implementation that fetches data from the MLB StatsAPI. + """ + + def fetch_teams(self): + """Fetches all MLB teams and returns a mapping of ID to abbreviation.""" + try: + teams_data = statsapi.get('teams', {'sportId': 1})['teams'] + return {t['id']: t.get('abbreviation', t['name'][:3].upper()) for t in teams_data} + except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError) as e: + logger.error("Failed to fetch teams: %s", e) + # Fallback to some common ones if API fails + return {147: 'NYY', 110: 'BAL', 119: 'LAD'} + + def fetch_schedule(self, date_str): + """Fetches the MLB schedule for a specific date.""" + try: + return statsapi.schedule(date=date_str) + except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError) as e: + logger.error("Failed to fetch schedule for %s: %s", date_str, e) + raise APIError(f"Unable to fetch schedule for {date_str}") from e + + def fetch_standings(self): + """Fetches current MLB standings.""" + try: + data = statsapi.get('standings', {'leagueId': '103,104'}) + if not data or not data.get('records'): + return [] + + div_results = [] + for record in data['records']: + div_id = record.get('division', {}).get('id') + if div_id: + div_results.append({ + 'id': div_id, + 'name': DIVISION_NAMES.get( + div_id, record.get('division', {}).get('name', 'Unknown') + ), + 'teams': record.get('teamRecords', []) + }) + return div_results + except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError) as e: + logger.error("Failed to fetch standings: %s", e) + raise APIError("Unable to fetch standings") from e + + def fetch_wild_card(self, league_id): + """Fetches wild card standings for a league.""" + try: + data = statsapi.get('standings', { + 'leagueId': league_id, + 'standingsTypes': 'wildCard' + }) + if not data or not data.get('records'): + return None + return data['records'][0] + except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError) as e: + logger.error("Failed to fetch wild card for league %s: %s", league_id, e) + raise APIError(f"Unable to fetch wild card standings for league {league_id}") from e diff --git a/app/screens/__init__.py b/app/screens/__init__.py index 92eae05..68aed6d 100644 --- a/app/screens/__init__.py +++ b/app/screens/__init__.py @@ -4,5 +4,6 @@ from .schedule_screen import ScheduleScreen from .standings_screen import StandingsScreen from .calendar_screen import CalendarScreen +from .error_screen import ErrorScreen -__all__ = ["ScheduleScreen", "StandingsScreen", "CalendarScreen"] +__all__ = ["ScheduleScreen", "StandingsScreen", "CalendarScreen", "ErrorScreen"] diff --git a/app/screens/error_screen.py b/app/screens/error_screen.py new file mode 100644 index 0000000..f551a29 --- /dev/null +++ b/app/screens/error_screen.py @@ -0,0 +1,42 @@ +""" +Error screen for the MLB CLI application. +Displays error messages and recovery instructions. +""" +import pytermgui as ptg +from app.widgets import NavigationWidget + + +class ErrorScreen: + # pylint: disable=too-few-public-methods + """ + Screen class for displaying error messages. + """ + + @staticmethod + def get_widgets(error_message): + """ + Generates the widget list and title for an error message. + + Args: + error_message (str): The error message to display. + + Returns: + tuple: (list of widgets, title string) + """ + widgets = [ + NavigationWidget(), # Default nav for recovery + ptg.Label(""), + ptg.Label("[bold red]ERROR[/]", parent_align=ptg.HorizontalAlignment.CENTER), + ptg.Label(""), + ptg.Label(f"[italic]{error_message}[/]", parent_align=ptg.HorizontalAlignment.CENTER), + ptg.Label(""), + ptg.Label("Please check your connection or try again later.", + parent_align=ptg.HorizontalAlignment.CENTER), + ptg.Label(""), + ptg.Label("Press [bold cyan]t[/] to return to Today's schedule.", + parent_align=ptg.HorizontalAlignment.CENTER), + ptg.Label("Press [bold cyan]ESC[/] to exit.", + parent_align=ptg.HorizontalAlignment.CENTER), + ptg.Label(""), + ] + return widgets, "[red]Application Error[/]" diff --git a/app/state.py b/app/state.py new file mode 100644 index 0000000..122a222 --- /dev/null +++ b/app/state.py @@ -0,0 +1,74 @@ +""" +Application state management for the MLB CLI application. +Handles dates, page tracking, and navigation logic. +""" +from datetime import datetime, timedelta + + +class ApplicationState: + """ + Manages the persistent state of the application. + Tracks the current date, active page, and calendar pagination. + """ + + def __init__(self): + self.current_date = datetime.now() + self.active_page = None + self.calendar_page = 0 + self.determine_initial_calendar_page() + + def determine_initial_calendar_page(self): + """Determines the calendar page based on current_date.""" + month = self.current_date.month + if month <= 5: + self.calendar_page = 0 + elif month <= 8: + self.calendar_page = 1 + else: + self.calendar_page = 2 + + def increment_date(self): + """Increments the current date, with season boundary logic.""" + if self.current_date.year < 2026: + self.current_date = datetime(2026, 1, 1) + + if self.current_date < datetime(2026, 12, 31): + self.current_date += timedelta(days=1) + return True + return False + + def decrement_date(self): + """Decrements the current date, with season boundary logic.""" + if self.current_date.year > 2026: + self.current_date = datetime(2026, 12, 31) + + if self.current_date > datetime(2026, 1, 1): + self.current_date -= timedelta(days=1) + return True + return False + + def next_calendar_page(self): + """Moves to the next calendar page with wrapping.""" + self.calendar_page = (self.calendar_page + 1) % 3 + + def prev_calendar_page(self): + """Moves to the previous calendar page with wrapping.""" + self.calendar_page = (self.calendar_page - 1) % 3 + + def reset_to_today(self): + """Resets current_date to the actual today.""" + self.current_date = datetime.now() + + def set_active_page(self, page_name): + """Updates the active page name.""" + self.active_page = page_name + + @property + def on_calendar_screen(self): + """Returns True if currently on a calendar page.""" + return self.active_page and self.active_page.startswith("calendar") + + @property + def on_standings_screen(self): + """Returns True if currently on the standings page.""" + return self.active_page == "standings" diff --git a/mlb_cli.py b/mlb_cli.py index f12b3a1..6303362 100644 --- a/mlb_cli.py +++ b/mlb_cli.py @@ -11,52 +11,38 @@ from app.screens import ( ScheduleScreen, StandingsScreen, - CalendarScreen + CalendarScreen, + ErrorScreen ) from app.widgets import CalendarWidget, slide_transition +from app.config import STATIC_WIDTH, INITIAL_HEIGHT +from app.state import ApplicationState +from app.exceptions import APIError +from app.logger import get_logger -# TODO: Consider making this dynamic as opposed to a hardcoded value. -# This value ensures a the initial calendar view is correct, assuming -# a 3 month view. -INITIAL_HEIGHT = 36 +logger = get_logger(__name__) class MLBApp: """ - Main application class that manages the WindowManager and screen transitions. + TUI Manager class for the MLB CLI application. + Handles window management, screen transitions, and global keybindings. """ # pylint: disable=too-many-instance-attributes - # pylint: disable=too-many-instance-attributes - def __init__(self): - """Initializes the application state and UI components.""" + """Initializes the application UI and logic state.""" fetch_teams() self.manager = ptg.WindowManager() - self.static_width = 80 + self.state = ApplicationState() + self.static_width = STATIC_WIDTH self.static_height = 0 # Will be set in run() self.main_window = None self.is_initialized = False - self.active_page = None - # Initialize to today (May 15, 2026 per context) - self.current_date = datetime.now() - # Pages: 0 (Mar, Apr, May), 1 (Jun, Jul, Aug), 2 (Sep, Oct) - self.calendar_page = 0 - self._determine_initial_calendar_page() - - def _determine_initial_calendar_page(self): - """Determines the calendar page based on current date.""" - month = self.current_date.month - if month <= 5: - self.calendar_page = 0 - elif month <= 8: - self.calendar_page = 1 - else: - self.calendar_page = 2 def set_window_data(self, widgets, title, page_name, on_finish=None): """Sets content for the main window, using animation if already initialized.""" - if self.active_page == page_name: + if self.state.active_page == page_name: if on_finish: on_finish() return @@ -67,7 +53,7 @@ def set_window_data(self, widgets, title, page_name, on_finish=None): temp_window.set_title(title) target_height = min(max_height, temp_window.height) - self.active_page = page_name + self.state.set_active_page(page_name) if not self.is_initialized: self.is_initialized = True self.main_window.set_widgets(widgets) @@ -90,74 +76,83 @@ def set_window_data(self, widgets, title, page_name, on_finish=None): new_height=target_height ) + @staticmethod + def handle_errors(func): + """Decorator to handle errors during screen transitions.""" + def wrapper(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + except (APIError, Exception) as e: # pylint: disable=broad-exception-caught + logger.error("Error in %s: %s", func.__name__, e, exc_info=True) + # Provide a user-friendly message for generic exceptions + msg = str(e) if isinstance(e, APIError) else "An unexpected error occurred" + widgets, title = ErrorScreen.get_widgets(msg) + self.set_window_data(widgets, title, "error") + return False + return wrapper + + @handle_errors def update_to_schedule(self, *_args, **_kwargs): """Transitions the main window to show the schedule for current_date.""" - date_str = format_date(self.current_date) + date_str = format_date(self.state.current_date) widgets, title = ScheduleScreen.get_widgets(date_str) self.set_window_data(widgets, title, f"schedule:{date_str}") return True def go_to_previous_day(self, *_args, **_kwargs): """Decrements the current date or page.""" - if self.active_page and self.active_page.startswith("calendar"): + if self.state.on_calendar_screen: return self.go_to_previous_page() - if self.current_date.year > 2026: - self.current_date = datetime(2026, 12, 31) - - if self.current_date > datetime(2026, 1, 1): - self.current_date -= timedelta(days=1) + if self.state.decrement_date(): return self.update_to_schedule() return True def go_to_next_day(self, *_args, **_kwargs): """Increments the current date or page.""" - if self.active_page and self.active_page.startswith("calendar"): + if self.state.on_calendar_screen: return self.go_to_next_page() - if self.current_date.year < 2026: - - self.current_date = datetime(2026, 1, 1) - - if self.current_date < datetime(2026, 12, 31): - self.current_date += timedelta(days=1) + if self.state.increment_date(): return self.update_to_schedule() return True + @handle_errors def toggle_standings(self, *_args, **_kwargs): """Toggles between standings and schedule/calendar.""" - if self.active_page == "standings": + if self.state.on_standings_screen: return self.update_to_schedule() widgets, title = StandingsScreen.get_widgets() self.set_window_data(widgets, title, "standings") return True + @handle_errors def update_to_calendar(self, *_args, sync_page=True, focus_target=None, **_kwargs): """Transitions to the calendar view.""" - if self.active_page == "standings": - self.current_date = datetime.now() + if self.state.on_standings_screen: + self.state.reset_to_today() if sync_page: # Ensure calendar_page matches current_date - self._determine_initial_calendar_page() + self.state.determine_initial_calendar_page() pages = [ [3, 4, 5], [6, 7, 8], [9, 10] ] - months = pages[self.calendar_page] + months = pages[self.state.calendar_page] widgets, title = CalendarScreen.get_widgets( 2026, months, self.on_calendar_date_selected, - selected_date=self.current_date + selected_date=self.state.current_date ) self.set_window_data( widgets, title, - f"calendar:{self.calendar_page}", + f"calendar:{self.state.calendar_page}", on_finish=lambda: self._focus_current_date_in_calendar(target=focus_target) ) return True @@ -193,8 +188,8 @@ def _focus_current_date_in_calendar(self, target=None): else: # Default: focus current_date for sub in calendar_widgets: - if sub.month == self.current_date.month: - day = self.current_date.day + if sub.month == self.state.current_date.month: + day = self.state.current_date.day if day in sub.day_to_button: target_btn = sub.day_to_button[day] break @@ -209,7 +204,7 @@ def _focus_current_date_in_calendar(self, target=None): def on_calendar_date_selected(self, year, month, day): """Callback for when a date is selected in the calendar.""" - self.current_date = datetime(year, month, day) + self.state.current_date = datetime(year, month, day) return self.update_to_schedule() def _navigate_calendar(self, direction): @@ -218,7 +213,7 @@ def _navigate_calendar(self, direction): Global WASD navigation for the calendar. Moves focus between buttons based on date logic. """ - if not (self.active_page and self.active_page.startswith("calendar")): + if not self.state.on_calendar_screen: return False focused = self.manager.focused @@ -277,17 +272,17 @@ def _navigate_calendar(self, direction): def go_to_previous_page(self, *_args, **_kwargs): """Moves calendar view to the previous page with wrapping.""" - self.calendar_page = (self.calendar_page - 1) % 3 + self.state.prev_calendar_page() return self.update_to_calendar(sync_page=False, focus_target="last") def go_to_next_page(self, *_args, **_kwargs): """Moves calendar view to the next page with wrapping.""" - self.calendar_page = (self.calendar_page + 1) % 3 + self.state.next_calendar_page() return self.update_to_calendar(sync_page=False, focus_target="first") def go_to_today(self, *_args, **_kwargs): """Resets the current date to today and updates the view.""" - self.current_date = datetime.now() + self.state.reset_to_today() return self.update_to_schedule() def exit_app(self, *_args, **_kwargs): diff --git a/tests/models/test_data_service.py b/tests/models/test_data_service.py index 9292313..d84e9cc 100644 --- a/tests/models/test_data_service.py +++ b/tests/models/test_data_service.py @@ -4,6 +4,7 @@ import unittest from unittest.mock import patch from datetime import datetime +from app.exceptions import APIError from app.models.data_service import ( fetch_teams, get_team_abbr, @@ -77,6 +78,14 @@ def test_fetch_schedule(self, mock_schedule): mock_schedule.assert_called_with(date='01/01/2024') self.assertEqual(result, [{'game_id': 123}]) + @patch('statsapi.schedule') + def test_fetch_schedule_failure(self, mock_schedule): + """Test fetch_schedule handling API failure.""" + mock_schedule.side_effect = RuntimeError("API Down") + with patch('app.models.data_service.get_cached_data', return_value=None): + with self.assertRaises(APIError): + fetch_schedule('01/01/2024') + @patch('app.models.data_service.get_cached_data') def test_fetch_schedule_cache_hit(self, mock_cache): """Test fetch_schedule returns cached data.""" @@ -188,8 +197,8 @@ def test_fetch_wild_card_failure(self, mock_get): mock_get.side_effect = RuntimeError("API Down") # Mock cache miss with patch('app.models.data_service.get_cached_data', return_value=None): - result = fetch_wild_card(103) - self.assertIsNone(result) + with self.assertRaises(APIError): + fetch_wild_card(103) @patch('statsapi.get') def test_fetch_wild_card_truncation(self, mock_get): @@ -246,9 +255,8 @@ def test_fetch_standings_failure(self, mock_get, _mock_wc): """Test fetch_standings handling API failure.""" mock_get.side_effect = RuntimeError("API Down") with patch('app.models.data_service.get_cached_data', return_value=None): - al, nl, _, _ = fetch_standings() - self.assertEqual(al, []) - self.assertEqual(nl, []) + with self.assertRaises(APIError): + fetch_standings() @patch('app.models.data_service.fetch_wild_card') @patch('app.models.data_service.get_cached_data', return_value=None) diff --git a/tests/test_app_lifecycle.py b/tests/test_app_lifecycle.py new file mode 100644 index 0000000..7f646c3 --- /dev/null +++ b/tests/test_app_lifecycle.py @@ -0,0 +1,95 @@ +""" +Unit tests for the application lifecycle of MLBApp. +""" +import unittest +from unittest.mock import patch, MagicMock +import pytermgui as ptg +from mlb_cli import MLBApp, main + + +class TestAppLifecycle(unittest.TestCase): + """Test cases for the MLBApp lifecycle (init, run, exit).""" + # pylint: disable=duplicate-code + + def setUp(self): + """Initialize MLBApp with mocked WindowManager.""" + with patch('mlb_cli.fetch_teams'), \ + patch('mlb_cli.ptg.WindowManager') as mock_manager: + self.app = MLBApp() + self.app.manager = mock_manager.return_value + self.app.manager.terminal.width = 100 + self.app.manager.terminal.height = 40 + self.app.main_window = MagicMock(spec=ptg.Window) + self.app.main_window.__iter__.return_value = iter([]) + + def test_init(self): + """Test MLBApp initialization.""" + self.assertEqual(self.app.static_width, 80) + self.assertFalse(self.app.is_initialized) + self.assertIsNone(self.app.state.active_page) + + def test_set_window_data_initial(self): + """Test initial call to set_window_data.""" + widgets = [ptg.Label("test")] + self.app.set_window_data(widgets, "Title", "page1") + + self.assertTrue(self.app.is_initialized) + self.assertEqual(self.app.state.active_page, "page1") + self.app.main_window.set_widgets.assert_called_with(widgets) + self.app.main_window.set_title.assert_called_with("Title") + + def test_set_window_data_same_page(self): + """Test set_window_data with the same page name does nothing.""" + self.app.state.active_page = "page1" + self.app.main_window.set_widgets.reset_mock() + self.app.set_window_data([], "Title", "page1") + self.app.main_window.set_widgets.assert_not_called() + + def test_set_window_data_same_page_with_callback(self): + """Test set_window_data with the same page name and a callback.""" + self.app.state.active_page = "page1" + mock_finish = MagicMock() + self.app.set_window_data([], "Title", "page1", on_finish=mock_finish) + mock_finish.assert_called_once() + + @patch('mlb_cli.slide_transition') + def test_set_window_data_transition(self, mock_transition): + """Test set_window_data triggers slide_transition for subsequent calls.""" + self.app.is_initialized = True + self.app.state.active_page = "page1" + widgets = [ptg.Label("test")] + + self.app.set_window_data(widgets, "New Title", "page2") + + self.assertEqual(self.app.state.active_page, "page2") + mock_transition.assert_called_once() + + def test_exit_app(self): + """Test application exit.""" + self.app.exit_app() + self.app.manager.stop.assert_called_once() + + @patch('mlb_cli.ptg.Window') + def test_run(self, _mock_window): + """Test main run loop setup.""" + # Mock run to exit immediately + self.app.manager.run.side_effect = None + + with patch.object(self.app, 'update_to_calendar') as mock_update: + self.app.run() + + self.app.manager.add.assert_called_once() + mock_update.assert_called_once() + self.app.manager.run.assert_called_once() + + @patch('mlb_cli.MLBApp') + def test_main(self, mock_app_class): + """Test the main() entry point.""" + mock_app_instance = mock_app_class.return_value + main() + mock_app_class.assert_called_once() + mock_app_instance.run.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_application_state.py b/tests/test_application_state.py new file mode 100644 index 0000000..74b0c0d --- /dev/null +++ b/tests/test_application_state.py @@ -0,0 +1,109 @@ +""" +Unit tests for the ApplicationState class. +""" +import unittest +from datetime import datetime +from app.state import ApplicationState + + +class TestApplicationState(unittest.TestCase): + """Test cases for pure logic in ApplicationState.""" + + def setUp(self): + self.state = ApplicationState() + + def test_init(self): + """Test initial state.""" + self.assertIsInstance(self.state.current_date, datetime) + self.assertIsNone(self.state.active_page) + # Should match today's month + month = datetime.now().month + if month <= 5: + self.assertEqual(self.state.calendar_page, 0) + elif month <= 8: + self.assertEqual(self.state.calendar_page, 1) + else: + self.assertEqual(self.state.calendar_page, 2) + + def test_determine_initial_calendar_page(self): + """Test page determination logic.""" + self.state.current_date = datetime(2026, 4, 1) + self.state.determine_initial_calendar_page() + self.assertEqual(self.state.calendar_page, 0) + + self.state.current_date = datetime(2026, 7, 1) + self.state.determine_initial_calendar_page() + self.assertEqual(self.state.calendar_page, 1) + + self.state.current_date = datetime(2026, 11, 1) + self.state.determine_initial_calendar_page() + self.assertEqual(self.state.calendar_page, 2) + + def test_increment_date(self): + """Test date incrementing with boundaries.""" + # 1. Normal increment + self.state.current_date = datetime(2026, 5, 15) + self.assertTrue(self.state.increment_date()) + self.assertEqual(self.state.current_date, datetime(2026, 5, 16)) + + # 2. Boundary snap + self.state.current_date = datetime(2025, 12, 31) + self.assertTrue(self.state.increment_date()) + # Snap to Jan 1, then increment to Jan 2 + self.assertEqual(self.state.current_date, datetime(2026, 1, 2)) + + # 3. Upper limit + self.state.current_date = datetime(2026, 12, 31) + self.assertFalse(self.state.increment_date()) + self.assertEqual(self.state.current_date, datetime(2026, 12, 31)) + + def test_decrement_date(self): + """Test date decrementing with boundaries.""" + # 1. Normal decrement + self.state.current_date = datetime(2026, 5, 15) + self.assertTrue(self.state.decrement_date()) + self.assertEqual(self.state.current_date, datetime(2026, 5, 14)) + + # 2. Boundary snap + self.state.current_date = datetime(2027, 1, 1) + self.assertTrue(self.state.decrement_date()) + # Snap to Dec 31, then decrement to Dec 30 + self.assertEqual(self.state.current_date, datetime(2026, 12, 30)) + + # 3. Lower limit + self.state.current_date = datetime(2026, 1, 1) + self.assertFalse(self.state.decrement_date()) + self.assertEqual(self.state.current_date, datetime(2026, 1, 1)) + + def test_calendar_pagination(self): + """Test calendar page wrapping.""" + self.state.calendar_page = 2 + self.state.next_calendar_page() + self.assertEqual(self.state.calendar_page, 0) + + self.state.calendar_page = 0 + self.state.prev_calendar_page() + self.assertEqual(self.state.calendar_page, 2) + + def test_reset_to_today(self): + """Test resetting date.""" + self.state.current_date = datetime(2020, 1, 1) + self.state.reset_to_today() + self.assertEqual( + self.state.current_date.strftime('%Y-%m-%d'), + datetime.now().strftime('%Y-%m-%d') + ) + + def test_screen_properties(self): + """Test screen property helpers.""" + self.state.active_page = "calendar:0" + self.assertTrue(self.state.on_calendar_screen) + self.assertFalse(self.state.on_standings_screen) + + self.state.active_page = "standings" + self.assertFalse(self.state.on_calendar_screen) + self.assertTrue(self.state.on_standings_screen) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_mlb_cli.py b/tests/test_calendar_interaction.py similarity index 52% rename from tests/test_mlb_cli.py rename to tests/test_calendar_interaction.py index cfd7b56..849b5fd 100644 --- a/tests/test_mlb_cli.py +++ b/tests/test_calendar_interaction.py @@ -1,16 +1,17 @@ """ -Unit tests for the main mlb_cli application. +Unit tests for calendar-specific interactions in MLBApp. """ import unittest from unittest.mock import patch, MagicMock -from datetime import datetime, timedelta +from datetime import datetime import pytermgui as ptg -from mlb_cli import MLBApp, main +from mlb_cli import MLBApp from app.widgets import CalendarWidget -class TestMLBApp(unittest.TestCase): - """Test cases for the MLBApp class.""" - # pylint: disable=too-many-public-methods,protected-access + +class TestCalendarInteraction(unittest.TestCase): + """Test cases for WASD navigation and date selection in the calendar.""" + # pylint: disable=protected-access,duplicate-code def setUp(self): """Initialize MLBApp with mocked WindowManager.""" @@ -23,77 +24,13 @@ def setUp(self): self.app.main_window = MagicMock(spec=ptg.Window) self.app.main_window.__iter__.return_value = iter([]) - def test_init(self): - """Test MLBApp initialization.""" - self.assertEqual(self.app.static_width, 80) - self.assertFalse(self.app.is_initialized) - self.assertIsNone(self.app.active_page) - - def test_set_window_data_initial(self): - """Test initial call to set_window_data.""" - widgets = [ptg.Label("test")] - self.app.set_window_data(widgets, "Title", "page1") - - self.assertTrue(self.app.is_initialized) - self.assertEqual(self.app.active_page, "page1") - self.app.main_window.set_widgets.assert_called_with(widgets) - self.app.main_window.set_title.assert_called_with("Title") - - def test_set_window_data_same_page(self): - """Test set_window_data with the same page name does nothing.""" - self.app.active_page = "page1" - self.app.main_window.set_widgets.reset_mock() - self.app.set_window_data([], "Title", "page1") - self.app.main_window.set_widgets.assert_not_called() - - def test_set_window_data_same_page_with_callback(self): - """Test set_window_data with the same page name and a callback.""" - self.app.active_page = "page1" - mock_finish = MagicMock() - self.app.set_window_data([], "Title", "page1", on_finish=mock_finish) - mock_finish.assert_called_once() - - def test_go_to_previous_day_limits(self): - """Test season boundaries for previous day.""" - # Test upper limit (should snap to 2026/12/30) - self.app.current_date = datetime(2027, 1, 1) - with patch.object(self.app, 'update_to_schedule'): - self.app.go_to_previous_day() - self.assertEqual(self.app.current_date, datetime(2026, 12, 30)) - - # Test lower limit (should not decrement) - self.app.current_date = datetime(2026, 1, 1) - self.assertTrue(self.app.go_to_previous_day()) - self.assertEqual(self.app.current_date, datetime(2026, 1, 1)) - - def test_go_to_next_day_limits(self): - """Test season boundaries for next day.""" - # Test lower limit (should snap to 2026/01/02) - self.app.current_date = datetime(2025, 1, 1) - with patch.object(self.app, 'update_to_schedule'): - self.app.go_to_next_day() - self.assertEqual(self.app.current_date, datetime(2026, 1, 2)) - - # Test upper limit (should not increment) - self.app.current_date = datetime(2026, 12, 31) - self.assertTrue(self.app.go_to_next_day()) - self.assertEqual(self.app.current_date, datetime(2026, 12, 31)) - def test_focus_current_date_in_calendar(self): """Test _focus_current_date_in_calendar with various widget structures.""" - # Miss: empty window - self.app.main_window.__iter__.return_value = iter([]) - self.assertFalse(self.app._focus_current_date_in_calendar()) - - # Miss: not a container - self.app.main_window.__iter__.return_value = iter([ptg.Label("test")]) - self.assertFalse(self.app._focus_current_date_in_calendar()) - # Hit: deep structure mock_btn = MagicMock(spec=ptg.Button) mock_cal = MagicMock(spec=CalendarWidget) - mock_cal.month = self.app.current_date.month - mock_cal.day_to_button = {self.app.current_date.day: mock_btn} + mock_cal.month = self.app.state.current_date.month + mock_cal.day_to_button = {self.app.state.current_date.day: mock_btn} mock_container = MagicMock(spec=ptg.Container) mock_container.__iter__.return_value = iter([mock_cal]) @@ -105,65 +42,15 @@ def test_focus_current_date_in_calendar(self): self.app.main_window.select.assert_called_with(0) self.assertEqual(self.app.manager.focused, mock_btn) - @patch('mlb_cli.slide_transition') - def test_set_window_data_transition(self, mock_transition): - """Test set_window_data triggers slide_transition for subsequent calls.""" - self.app.is_initialized = True - self.app.active_page = "page1" - widgets = [ptg.Label("test")] - - self.app.set_window_data(widgets, "New Title", "page2") - - self.assertEqual(self.app.active_page, "page2") - mock_transition.assert_called_once() - - def test_determine_initial_calendar_page(self): - """Test initial calendar page based on month.""" - # Page 0 (<= May) - self.app.current_date = datetime(2026, 3, 15) - self.app._determine_initial_calendar_page() - self.assertEqual(self.app.calendar_page, 0) - - # Page 1 (<= Aug) - self.app.current_date = datetime(2026, 7, 15) - self.app._determine_initial_calendar_page() - self.assertEqual(self.app.calendar_page, 1) - - # Page 2 (> Aug) - self.app.current_date = datetime(2026, 10, 15) - self.app._determine_initial_calendar_page() - self.assertEqual(self.app.calendar_page, 2) - - def test_go_to_previous_day_calendar(self): - """Test go_to_previous_day while on calendar redirects to page.""" - self.app.active_page = "calendar:0" - with patch.object(self.app, 'go_to_previous_page') as mock_go: - self.app.go_to_previous_day() - mock_go.assert_called_once() - - def test_go_to_next_day_calendar(self): - """Test go_to_next_day while on calendar redirects to page.""" - self.app.active_page = "calendar:0" - with patch.object(self.app, 'go_to_next_page') as mock_go: - self.app.go_to_next_day() - mock_go.assert_called_once() - - def test_go_to_previous_page_boundary(self): - """Test boundary for go_to_previous_page (wraps).""" - self.app.calendar_page = 0 - self.assertTrue(self.app.go_to_previous_page()) - self.assertEqual(self.app.calendar_page, 2) - - def test_go_to_next_page_boundary(self): - """Test boundary for go_to_next_page (wraps).""" - self.app.calendar_page = 2 - self.assertTrue(self.app.go_to_next_page()) - self.assertEqual(self.app.calendar_page, 0) + def test_focus_current_date_in_calendar_not_a_container(self): + """Test _focus_current_date_in_calendar skips non-container widgets.""" + self.app.main_window.__iter__.return_value = iter([ptg.Label("test")]) + self.assertFalse(self.app._focus_current_date_in_calendar()) def test_focus_current_date_in_calendar_day_missing(self): """Test _focus_current_date_in_calendar when day is missing from button map.""" mock_cal = MagicMock(spec=CalendarWidget) - mock_cal.month = self.app.current_date.month + mock_cal.month = self.app.state.current_date.month mock_cal.day_to_button = {} # Day missing mock_container = MagicMock(spec=ptg.Container) @@ -175,7 +62,7 @@ def test_focus_current_date_in_calendar_day_missing(self): def test_focus_current_date_in_calendar_month_mismatch(self): """Test _focus_current_date_in_calendar when month doesn't match.""" mock_cal = MagicMock(spec=CalendarWidget) - mock_cal.month = self.app.current_date.month + 1 # Mismatch + mock_cal.month = self.app.state.current_date.month + 1 # Mismatch mock_container = MagicMock(spec=ptg.Container) mock_container.__iter__.return_value = iter([mock_cal]) @@ -183,50 +70,6 @@ def test_focus_current_date_in_calendar_month_mismatch(self): self.app.main_window.__iter__.return_value = iter([mock_container]) self.assertFalse(self.app._focus_current_date_in_calendar()) - @patch('mlb_cli.ScheduleScreen.get_widgets') - def test_update_to_schedule(self, mock_get): - """Test transition to schedule.""" - mock_get.return_value = ([], "Schedule") - self.app.update_to_schedule() - self.assertTrue(self.app.active_page.startswith("schedule:")) - - def test_go_to_previous_day(self): - """Test decrementing current date.""" - initial_date = self.app.current_date - with patch.object(self.app, 'update_to_schedule'): - self.app.go_to_previous_day() - self.assertEqual(self.app.current_date, initial_date - timedelta(days=1)) - - def test_go_to_next_day(self): - """Test incrementing current date.""" - initial_date = self.app.current_date - with patch.object(self.app, 'update_to_schedule'): - self.app.go_to_next_day() - self.assertEqual(self.app.current_date, initial_date + timedelta(days=1)) - - def test_go_to_today(self): - """Test resetting current date to today.""" - self.app.current_date = datetime(2026, 10, 1) - with patch.object(self.app, 'update_to_schedule'): - self.app.go_to_today() - # It should be today now - self.assertEqual( - self.app.current_date.strftime('%Y-%m-%d'), - datetime.now().strftime('%Y-%m-%d') - ) - - @patch('mlb_cli.CalendarScreen.get_widgets') - def test_update_to_calendar(self, mock_get): - """Test transition to calendar.""" - mock_get.return_value = ([], "Calendar") - with patch.object(self.app, 'set_window_data', wraps=self.app.set_window_data) as mock_set: - self.app.update_to_calendar() - self.assertEqual(self.app.active_page, f"calendar:{self.app.calendar_page}") - mock_set.assert_called_once() - _, kwargs = mock_set.call_args - # on_finish is now a lambda - self.assertTrue(callable(kwargs['on_finish'])) - def test_focus_current_date_in_calendar_targets(self): """Test _focus_current_date_in_calendar with first/last targets.""" mock_on_selected = MagicMock() @@ -249,40 +92,51 @@ def test_focus_current_date_in_calendar_targets(self): self.assertTrue(self.app._focus_current_date_in_calendar(target="last")) self.assertEqual(self.app.manager.focused, btn_june30) + @patch('mlb_cli.CalendarScreen.get_widgets') + def test_update_to_calendar(self, mock_get): + """Test transition to calendar.""" + mock_get.return_value = ([], "Calendar") + with patch.object(self.app, 'set_window_data', wraps=self.app.set_window_data) as mock_set: + self.app.update_to_calendar() + self.assertEqual(self.app.state.active_page, f"calendar:{self.app.state.calendar_page}") + mock_set.assert_called_once() + _, kwargs = mock_set.call_args + self.assertTrue(callable(kwargs['on_finish'])) + @patch('mlb_cli.CalendarScreen.get_widgets') def test_update_to_calendar_from_standings(self, mock_get): """Test that date is reset when returning from standings.""" mock_get.return_value = ([], "Calendar") - self.app.active_page = "standings" - self.app.current_date = datetime(2026, 1, 1) + self.app.state.active_page = "standings" + self.app.state.current_date = datetime(2026, 1, 1) self.app.update_to_calendar() # Should be today self.assertEqual( - self.app.current_date.strftime('%Y-%m-%d'), + self.app.state.current_date.strftime('%Y-%m-%d'), datetime.now().strftime('%Y-%m-%d') ) def test_go_to_previous_page(self): """Test decrementing calendar page with wrapping.""" - self.app.calendar_page = 0 + self.app.state.calendar_page = 0 with patch.object(self.app, 'update_to_calendar'): self.app.go_to_previous_page() - self.assertEqual(self.app.calendar_page, 2) + self.assertEqual(self.app.state.calendar_page, 2) def test_go_to_next_page(self): """Test incrementing calendar page with wrapping.""" - self.app.calendar_page = 2 + self.app.state.calendar_page = 2 with patch.object(self.app, 'update_to_calendar'): self.app.go_to_next_page() - self.assertEqual(self.app.calendar_page, 0) + self.assertEqual(self.app.state.calendar_page, 0) def test_pagination_no_mock(self): """Test that pagination actually works without mocking update_to_calendar.""" # Set date to May (should be page 0) - self.app.current_date = datetime(2026, 5, 15) - self.app.calendar_page = 0 + self.app.state.current_date = datetime(2026, 5, 15) + self.app.state.calendar_page = 0 # We need to mock CalendarScreen.get_widgets to avoid real UI creation issues in test with patch('app.screens.CalendarScreen.get_widgets') as mock_get: @@ -291,30 +145,30 @@ def test_pagination_no_mock(self): # Go to next page self.app.go_to_next_page() - self.assertEqual(self.app.calendar_page, 1) + self.assertEqual(self.app.state.calendar_page, 1) def test_on_calendar_date_selected(self): """Test selecting a date from calendar.""" with patch.object(self.app, 'update_to_schedule'): self.app.on_calendar_date_selected(2026, 5, 20) - self.assertEqual(self.app.current_date.year, 2026) - self.assertEqual(self.app.current_date.month, 5) - self.assertEqual(self.app.current_date.day, 20) + self.assertEqual(self.app.state.current_date.year, 2026) + self.assertEqual(self.app.state.current_date.month, 5) + self.assertEqual(self.app.state.current_date.day, 20) def test_navigate_calendar_state_checks(self): """Test _navigate_calendar basic state checks.""" # 1. Not on calendar page - self.app.active_page = "schedule" + self.app.state.active_page = "schedule" self.assertFalse(self.app._navigate_calendar("w")) # 2. On calendar page, but nothing focused - self.app.active_page = "calendar:0" + self.app.state.active_page = "calendar:0" self.app.manager.focused = None self.assertFalse(self.app._navigate_calendar("w")) def test_navigate_calendar_success(self): """Test successful WASD navigation in calendar.""" - self.app.active_page = "calendar:0" + self.app.state.active_page = "calendar:0" # Create CalendarWidget with day 1 and day 2 mock_on_selected = MagicMock() @@ -339,7 +193,7 @@ def test_navigate_calendar_success(self): def test_navigate_calendar_invalid_direction(self): """Test _navigate_calendar with invalid direction.""" - self.app.active_page = "calendar:0" + self.app.state.active_page = "calendar:0" cal = CalendarWidget(2026, 5, MagicMock()) btn1 = cal.day_to_button[1] self.app.manager.focused = btn1 @@ -351,7 +205,7 @@ def test_navigate_calendar_invalid_direction(self): def test_navigate_calendar_target_not_found(self): """Test _navigate_calendar when target button is not in view.""" - self.app.active_page = "calendar:0" + self.app.state.active_page = "calendar:0" cal = CalendarWidget(2026, 5, MagicMock()) btn31 = cal.day_to_button[31] # Last day of May self.app.manager.focused = btn31 @@ -364,7 +218,7 @@ def test_navigate_calendar_target_not_found(self): def test_navigate_calendar_not_a_container(self): """Test _navigate_calendar skips non-container widgets in both loops.""" - self.app.active_page = "calendar:0" + self.app.state.active_page = "calendar:0" cal = CalendarWidget(2026, 5, MagicMock()) btn1 = cal.day_to_button[1] @@ -385,7 +239,7 @@ def test_navigate_calendar_not_a_container(self): def test_navigate_calendar_focused_not_in_calendar(self): """Test _navigate_calendar when focused widget is not in a CalendarWidget.""" - self.app.active_page = "calendar:0" + self.app.state.active_page = "calendar:0" mock_btn = MagicMock(spec=ptg.Button) self.app.manager.focused = mock_btn @@ -397,7 +251,7 @@ def test_navigate_calendar_focused_not_in_calendar(self): def test_navigate_calendar_target_not_calendar_widget(self): """Test _navigate_calendar skips non-calendar sub-widgets when looking for target.""" - self.app.active_page = "calendar:0" + self.app.state.active_page = "calendar:0" cal = CalendarWidget(2026, 5, MagicMock()) btn1 = cal.day_to_button[1] @@ -413,7 +267,7 @@ def test_navigate_calendar_target_not_calendar_widget(self): def test_navigate_calendar_cross_widget(self): """Test navigation between two different CalendarWidgets.""" - self.app.active_page = "calendar:0" + self.app.state.active_page = "calendar:0" cal1 = CalendarWidget(2026, 5, MagicMock()) # May cal2 = CalendarWidget(2026, 6, MagicMock()) # June @@ -434,48 +288,6 @@ def test_navigate_calendar_cross_widget(self): self.app.main_window.select.assert_called_with(1) self.assertEqual(self.app.manager.focused, btn_june1) - @patch('mlb_cli.StandingsScreen.get_widgets') - def test_toggle_standings(self, mock_get): - """Test transition to standings and back.""" - mock_get.return_value = ([], "Standings") - - # Go to standings - self.app.toggle_standings() - self.assertEqual(self.app.active_page, "standings") - - # Go back to schedule - with patch.object(self.app, 'update_to_schedule') as mock_update: - self.app.toggle_standings() - mock_update.assert_called_once() - - def test_exit_app(self): - """Test application exit.""" - self.app.exit_app() - self.app.manager.stop.assert_called_once() - - @patch('mlb_cli.ptg.Window') - def test_run(self, _mock_window): - """Test main run loop setup.""" - # Mock terminal height - self.app.manager.terminal.height = 40 - - # Mock run to exit immediately - self.app.manager.run.side_effect = None - - with patch.object(self.app, 'update_to_calendar') as mock_update: - self.app.run() - - self.app.manager.add.assert_called_once() - mock_update.assert_called_once() - self.app.manager.run.assert_called_once() - - @patch('mlb_cli.MLBApp') - def test_main(self, mock_app_class): - """Test the main() entry point.""" - mock_app_instance = mock_app_class.return_value - main() - mock_app_class.assert_called_once() - mock_app_instance.run.assert_called_once() - -if __name__ == '__main__': # pragma: no cover + +if __name__ == '__main__': unittest.main() diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000..10ae989 --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,72 @@ +""" +Unit tests for the error handling logic in MLBApp. +""" +import unittest +from unittest.mock import patch, MagicMock +import pytermgui as ptg +from mlb_cli import MLBApp +from app.exceptions import APIError +from app.screens import ErrorScreen + + +class TestErrorHandling(unittest.TestCase): + """Test cases for the global exception handling and error screen.""" + + def setUp(self): + """Initialize MLBApp with mocked WindowManager.""" + with patch('mlb_cli.fetch_teams'), \ + patch('mlb_cli.ptg.WindowManager') as mock_manager: + self.app = MLBApp() + self.app.manager = mock_manager.return_value + self.app.manager.terminal.width = 100 + self.app.manager.terminal.height = 40 + self.app.main_window = MagicMock(spec=ptg.Window) + self.app.main_window.__iter__.return_value = iter([]) + + @patch('app.screens.ErrorScreen.get_widgets') + def test_handle_errors_decorator_api_error(self, mock_get_error): + """Test that APIError triggers ErrorScreen.""" + mock_get_error.return_value = ([], "Error Title") + + @MLBApp.handle_errors + def failing_method(app_instance): + raise APIError("API failure") + + with patch.object(self.app, 'set_window_data') as mock_set: + result = failing_method(self.app) + self.assertFalse(result) + mock_get_error.assert_called_with("API failure") + mock_set.assert_called_with([], "Error Title", "error") + + @patch('app.screens.ErrorScreen.get_widgets') + def test_handle_errors_decorator_generic_exception(self, mock_get_error): + """Test that generic Exception triggers user-friendly ErrorScreen message.""" + mock_get_error.return_value = ([], "Error Title") + + @MLBApp.handle_errors + def crashing_method(app_instance): + raise RuntimeError("Unexpected crash") + + with patch.object(self.app, 'set_window_data') as mock_set: + result = crashing_method(self.app) + self.assertFalse(result) + # Should NOT show the internal message "Unexpected crash" but a generic one + mock_get_error.assert_called_with("An unexpected error occurred") + mock_set.assert_called_with([], "Error Title", "error") + + def test_error_screen_widgets(self): + """Test that ErrorScreen returns the expected number of widgets.""" + widgets, title = ErrorScreen.get_widgets("Test Error") + self.assertEqual(title, "[red]Application Error[/]") + # NavigationWidget + Label x 10 + self.assertEqual(len(widgets), 11) + # Check if the error message is present in the labels + found = False + for w in widgets: + if hasattr(w, 'value') and "Test Error" in w.value: + found = True + break + self.assertTrue(found) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_navigation_logic.py b/tests/test_navigation_logic.py new file mode 100644 index 0000000..8ae99f8 --- /dev/null +++ b/tests/test_navigation_logic.py @@ -0,0 +1,143 @@ +""" +Unit tests for navigation logic and date manipulation in MLBApp. +""" +import unittest +from unittest.mock import patch, MagicMock +from datetime import datetime, timedelta +import pytermgui as ptg +from mlb_cli import MLBApp + + +class TestNavigationLogic(unittest.TestCase): + """Test cases for day/page navigation and boundaries.""" + # pylint: disable=duplicate-code + + def setUp(self): + """Initialize MLBApp with mocked WindowManager.""" + with patch('mlb_cli.fetch_teams'), \ + patch('mlb_cli.ptg.WindowManager') as mock_manager: + self.app = MLBApp() + self.app.manager = mock_manager.return_value + self.app.manager.terminal.width = 100 + self.app.manager.terminal.height = 40 + self.app.main_window = MagicMock(spec=ptg.Window) + self.app.main_window.__iter__.return_value = iter([]) + + def test_determine_initial_calendar_page(self): + """Test initial calendar page based on month.""" + # Page 0 (<= May) + self.app.state.current_date = datetime(2026, 3, 15) + self.app.state.determine_initial_calendar_page() + self.assertEqual(self.app.state.calendar_page, 0) + + # Page 1 (<= Aug) + self.app.state.current_date = datetime(2026, 7, 15) + self.app.state.determine_initial_calendar_page() + self.assertEqual(self.app.state.calendar_page, 1) + + # Page 2 (> Aug) + self.app.state.current_date = datetime(2026, 10, 15) + self.app.state.determine_initial_calendar_page() + self.assertEqual(self.app.state.calendar_page, 2) + + def test_go_to_previous_day_limits(self): + """Test season boundaries for previous day.""" + # Test upper limit (should snap to 2026/12/30) + self.app.state.current_date = datetime(2027, 1, 1) + with patch.object(self.app, 'update_to_schedule'): + self.app.go_to_previous_day() + self.assertEqual(self.app.state.current_date, datetime(2026, 12, 30)) + + # Test lower limit (should not decrement) + self.app.state.current_date = datetime(2026, 1, 1) + self.assertTrue(self.app.go_to_previous_day()) + self.assertEqual(self.app.state.current_date, datetime(2026, 1, 1)) + + def test_go_to_next_day_limits(self): + """Test season boundaries for next day.""" + # Test lower limit (should snap to 2026/01/02) + self.app.state.current_date = datetime(2025, 1, 1) + with patch.object(self.app, 'update_to_schedule'): + self.app.go_to_next_day() + self.assertEqual(self.app.state.current_date, datetime(2026, 1, 2)) + + # Test upper limit (should not increment) + self.app.state.current_date = datetime(2026, 12, 31) + self.assertTrue(self.app.go_to_next_day()) + self.assertEqual(self.app.state.current_date, datetime(2026, 12, 31)) + + def test_go_to_previous_day_calendar(self): + """Test go_to_previous_day while on calendar redirects to page.""" + self.app.state.active_page = "calendar:0" + with patch.object(self.app, 'go_to_previous_page') as mock_go: + self.app.go_to_previous_day() + mock_go.assert_called_once() + + def test_go_to_next_day_calendar(self): + """Test go_to_next_day while on calendar redirects to page.""" + self.app.state.active_page = "calendar:0" + with patch.object(self.app, 'go_to_next_page') as mock_go: + self.app.go_to_next_day() + mock_go.assert_called_once() + + def test_go_to_previous_page_boundary(self): + """Test boundary for go_to_previous_page (wraps).""" + self.app.state.calendar_page = 0 + self.assertTrue(self.app.go_to_previous_page()) + self.assertEqual(self.app.state.calendar_page, 2) + + def test_go_to_next_page_boundary(self): + """Test boundary for go_to_next_page (wraps).""" + self.app.state.calendar_page = 2 + self.assertTrue(self.app.go_to_next_page()) + self.assertEqual(self.app.state.calendar_page, 0) + + @patch('mlb_cli.ScheduleScreen.get_widgets') + def test_update_to_schedule(self, mock_get): + """Test transition to schedule.""" + mock_get.return_value = ([], "Schedule") + self.app.update_to_schedule() + self.assertTrue(self.app.state.active_page.startswith("schedule:")) + + def test_go_to_previous_day(self): + """Test decrementing current date.""" + initial_date = self.app.state.current_date + with patch.object(self.app, 'update_to_schedule'): + self.app.go_to_previous_day() + self.assertEqual(self.app.state.current_date, initial_date - timedelta(days=1)) + + def test_go_to_next_day(self): + """Test incrementing current date.""" + initial_date = self.app.state.current_date + with patch.object(self.app, 'update_to_schedule'): + self.app.go_to_next_day() + self.assertEqual(self.app.state.current_date, initial_date + timedelta(days=1)) + + def test_go_to_today(self): + """Test resetting current date to today.""" + self.app.state.current_date = datetime(2026, 10, 1) + with patch.object(self.app, 'update_to_schedule'): + self.app.go_to_today() + # It should be today now + self.assertEqual( + self.app.state.current_date.strftime('%Y-%m-%d'), + datetime.now().strftime('%Y-%m-%d') + ) + + @patch('mlb_cli.StandingsScreen.get_widgets') + def test_toggle_standings(self, mock_get): + """Test transition to standings and back.""" + mock_get.return_value = ([], "Standings") + + # Go to standings + self.app.toggle_standings() + self.assertEqual(self.app.state.active_page, "standings") + + # Go back to schedule + with patch.object(self.app, 'update_to_schedule') as mock_update: + self.app.toggle_standings() + mock_update.assert_called_once() + + +if __name__ == '__main__': + unittest.main()