From 06720c4aa29ea926fe9d8351957e17c73d12df47 Mon Sep 17 00:00:00 2001 From: Conor Cleary Date: Fri, 15 May 2026 13:51:42 -0500 Subject: [PATCH 01/11] Adding game status information to the GameWidget --- app/widgets/game_widgets.py | 44 ++++++++- tests/widgets/test_game_widgets.py | 140 +++++++++++++++++++++++------ 2 files changed, 153 insertions(+), 31 deletions(-) diff --git a/app/widgets/game_widgets.py b/app/widgets/game_widgets.py index d3d9918..bbcb462 100644 --- a/app/widgets/game_widgets.py +++ b/app/widgets/game_widgets.py @@ -2,6 +2,7 @@ Custom widgets and UI components for the MLB CLI application. Includes widgets for games, standings, navigation, and general layout. """ +from datetime import datetime, timezone import pytermgui as ptg from app.models.data_service import get_team_abbr @@ -38,6 +39,18 @@ def __init__(self, game, **kwargs): if status in ['Scheduled', 'Preview', 'Pre-Game']: away_score = "-" home_score = "-" + + # Local start time for scheduled games + try: + # Parse as naive UTC from string + dt_naive = datetime.strptime(game['game_datetime'], '%Y-%m-%dT%H:%M:%SZ') + # Convert to aware UTC object + dt_aware = dt_naive.replace(tzinfo=timezone.utc) + # Convert to system local time + local_time = dt_aware.astimezone() + inning_text = local_time.strftime('%-I:%M %p') + except (ValueError, KeyError, TypeError): + inning_text = "" else: away_score = game.get('away_score', "-") home_score = game.get('home_score', "-") @@ -46,10 +59,35 @@ def __init__(self, game, **kwargs): if home_score is None: home_score = "-" - self.away_label = ptg.Label(f"[bold]{away_abbr:3}[/] {away_score:>2}") - self.home_label = ptg.Label(f"[bold]{home_abbr:3}[/] {home_score:>2}") + # Inning data + inning_val = game.get('current_inning', '') + inning_state = game.get('inning_state', '') + if status == 'Final': + inning_text = "FINAL" + elif inning_val: + prefix = "" + if inning_state.lower().startswith('top'): + prefix = "TOP" + elif inning_state.lower().startswith('bottom'): + prefix = "BOT" + inning_text = f"{prefix} {inning_val}" + else: + inning_text = "" + + # Away Team Row + self.away_info = ptg.Splitter( + ptg.Label(f"[bold]{away_abbr:3}[/] {away_score:>2}"), + ptg.Label("", parent_align=ptg.HorizontalAlignment.RIGHT) + ) + + # Home Team Row + Status (Right Justified) + # We use a Splitter for the second row to separate Home and Status + self.home_info = ptg.Splitter( + ptg.Label(f"[bold]{home_abbr:3}[/] {home_score:>2}"), + ptg.Label(f"[italic]{inning_text}[/]", parent_align=ptg.HorizontalAlignment.CENTER) + ) - self.set_widgets([self.away_label, self.home_label]) + self.set_widgets([self.away_info, self.home_info]) self.border = ptg.boxes.SINGLE def handle_key(self, key): diff --git a/tests/widgets/test_game_widgets.py b/tests/widgets/test_game_widgets.py index 8f81206..82a9528 100644 --- a/tests/widgets/test_game_widgets.py +++ b/tests/widgets/test_game_widgets.py @@ -22,9 +22,25 @@ def test_separator_init(self): self.assertIsInstance(sep, ptg.Label) self.assertEqual(sep.value, "─" * 10) + def _get_label_values(self, widget): + """Helper to extract all label values from a widget's tree.""" + values = [] + if hasattr(widget, 'value'): + values.append(widget.value) + + # Check for child widgets in Containers or Splitters + # pylint: disable=protected-access + if hasattr(widget, '_widgets'): + for child in widget._widgets: + values.extend(self._get_label_values(child)) + elif hasattr(widget, 'widgets'): + for child in widget.widgets: + values.extend(self._get_label_values(child)) + return values + @patch('app.widgets.game_widgets.get_team_abbr') def test_game_widget_init(self, mock_abbr): - """Test GameWidget data mapping.""" + """Test GameWidget data mapping with inning data.""" mock_abbr.side_effect = lambda x: f"T{x}" game = { 'game_id': 123, @@ -32,47 +48,62 @@ def test_game_widget_init(self, mock_abbr): 'home_id': 2, 'away_score': 5, 'home_score': 3, - 'status': 'Final' + 'status': 'Final', + 'current_inning': 9, + 'inning_state': 'Top' } widget = GameWidget(game) self.assertEqual(widget.game_id, 123) - # Check if labels contain expected abbreviations and scores - self.assertIn("T1", widget.away_label.value) - self.assertIn("5", widget.away_label.value) - self.assertIn("T2", widget.home_label.value) - self.assertIn("3", widget.home_label.value) + + values = self._get_label_values(widget) + # Check for abbreviations and scores + self.assertTrue(any("T1" in v and "5" in v for v in values)) + self.assertTrue(any("T2" in v and "3" in v for v in values)) + # Check inning label for Final game + self.assertTrue(any("FINAL" in v for v in values)) @patch('app.widgets.game_widgets.get_team_abbr') - def test_standing_widget_init(self, mock_abbr): - """Test StandingWidget data mapping with pct and l10.""" - mock_abbr.side_effect = lambda x: f"T{x}" - div_data = { - 'div_name': 'American League East', - 'teams': [ - {'team_id': 1, 'w': 90, 'l': 72, 'gb': '-', 'pct': '55.6%', 'l10': '6-4'} - ] + def test_game_widget_in_progress(self, mock_abbr): + """Test GameWidget with in-progress inning data.""" + mock_abbr.return_value = "TEST" + # Top of 5th + game = { + 'game_id': 123, + 'away_id': 1, + 'home_id': 2, + 'status': 'In Progress', + 'current_inning': 5, + 'inning_state': 'Top' } - widget = StandingWidget(div_data) - labels = [w.value for w in widget.inner_widgets if isinstance(w, ptg.Label)] - self.assertTrue(any("AL East" in l for l in labels)) - self.assertTrue(any( - "T1" in l and "90" in l and "72" in l and "55.6%" in l and "6-4" in l - for l in labels - )) + widget = GameWidget(game) + values = self._get_label_values(widget) + self.assertTrue(any("TOP 5" in v for v in values)) + + # Bottom of 7th + game['inning_state'] = 'Bottom' + game['current_inning'] = 7 + widget = GameWidget(game) + values = self._get_label_values(widget) + self.assertTrue(any("BOT 7" in v for v in values)) @patch('app.widgets.game_widgets.get_team_abbr') def test_game_widget_scheduled(self, mock_abbr): - """Test GameWidget with scheduled status shows '-' for scores.""" + """Test GameWidget with scheduled status shows start time.""" mock_abbr.return_value = "TEST" game = { 'game_id': 123, 'away_id': 1, 'home_id': 2, - 'status': 'Scheduled' + 'status': 'Scheduled', + 'game_datetime': '2026-05-15T22:40:00Z' } widget = GameWidget(game) - self.assertIn("-", widget.away_label.value) - self.assertIn("-", widget.home_label.value) + values = self._get_label_values(widget) + + # Check for '-' scores + self.assertTrue(any("TEST" in v and "-" in v for v in values)) + # Check for time (should contain 'AM' or 'PM' and ':') + self.assertTrue(any(":" in v and ("AM" in v or "PM" in v) for v in values)) @patch('app.widgets.game_widgets.get_team_abbr') def test_game_widget_none_scores(self, mock_abbr): @@ -87,8 +118,59 @@ def test_game_widget_none_scores(self, mock_abbr): 'status': 'Final' } widget = GameWidget(game) - self.assertIn("-", widget.away_label.value) - self.assertIn("-", widget.home_label.value) + values = self._get_label_values(widget) + self.assertTrue(any("-" in v for v in values)) + + @patch('app.widgets.game_widgets.get_team_abbr') + def test_game_widget_malformed_time(self, mock_abbr): + """Test GameWidget with malformed game_datetime.""" + mock_abbr.return_value = "TEST" + game = { + 'game_id': 123, + 'away_id': 1, + 'home_id': 2, + 'status': 'Scheduled', + 'game_datetime': 'invalid-time' + } + widget = GameWidget(game) + values = self._get_label_values(widget) + # inning_text should be empty string + self.assertTrue(all(v != "invalid-time" for v in values)) + + @patch('app.widgets.game_widgets.get_team_abbr') + def test_game_widget_in_progress_no_inning(self, mock_abbr): + """Test GameWidget in-progress but with no inning data.""" + mock_abbr.return_value = "TEST" + game = { + 'game_id': 123, + 'away_id': 1, + 'home_id': 2, + 'status': 'In Progress', + 'current_inning': None, + 'inning_state': None + } + widget = GameWidget(game) + values = self._get_label_values(widget) + # Should not crash, and status should be empty + self.assertTrue(any("TEST" in v for v in values)) + + @patch('app.widgets.game_widgets.get_team_abbr') + def test_standing_widget_init(self, mock_abbr): + """Test StandingWidget data mapping with pct and l10.""" + mock_abbr.side_effect = lambda x: f"T{x}" + div_data = { + 'div_name': 'American League East', + 'teams': [ + {'team_id': 1, 'w': 90, 'l': 72, 'gb': '-', 'pct': '55.6%', 'l10': '6-4'} + ] + } + widget = StandingWidget(div_data) + labels = [w.value for w in widget.inner_widgets if isinstance(w, ptg.Label)] + self.assertTrue(any("AL East" in l for l in labels)) + self.assertTrue(any( + "T1" in l and "90" in l and "72" in l and "55.6%" in l and "6-4" in l + for l in labels + )) def test_game_widget_handle_key(self): """Test GameWidget handle_key returns super().handle_key().""" @@ -99,6 +181,8 @@ def test_game_widget_handle_key(self): 'status': 'Final' } widget = GameWidget(game) + # Test RETURN key to cover the branch + widget.handle_key(ptg.keys.RETURN) # Mock super().handle_key to return True with patch('pytermgui.Container.handle_key', return_value=True): self.assertTrue(widget.handle_key(ptg.keys.RETURN)) From 03a2ef28bdbedb8e5f362968c2f4a519ef2e91ae Mon Sep 17 00:00:00 2001 From: Conor Cleary Date: Fri, 15 May 2026 15:52:14 -0500 Subject: [PATCH 02/11] cleanup --- app/widgets/game_widgets.py | 90 ++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/app/widgets/game_widgets.py b/app/widgets/game_widgets.py index bbcb462..7ef5c86 100644 --- a/app/widgets/game_widgets.py +++ b/app/widgets/game_widgets.py @@ -34,61 +34,69 @@ def __init__(self, game, **kwargs): away_abbr = get_team_abbr(game['away_id']) home_abbr = get_team_abbr(game['home_id']) - # Determine scores status = game.get('status', '') + away_score, home_score = self._get_scores(game, status) + inning_text = self._get_inning_text(game, status) + + self.away_info, self.home_info = self._create_rows( + (away_abbr, away_score), + (home_abbr, home_score), + inning_text + ) + + self.set_widgets([self.away_info, self.home_info]) + self.border = ptg.boxes.SINGLE + + def _get_scores(self, game, status): + """Determines the away and home scores based on game status.""" if status in ['Scheduled', 'Preview', 'Pre-Game']: - away_score = "-" - home_score = "-" + return "-", "-" - # Local start time for scheduled games + away_score = game.get('away_score', "-") + home_score = game.get('home_score', "-") + return (str(away_score) if away_score is not None else "-", + str(home_score) if home_score is not None else "-") + + def _get_inning_text(self, game, status): + """Formats the inning or start time text.""" + if status in ['Scheduled', 'Preview', 'Pre-Game']: try: - # Parse as naive UTC from string dt_naive = datetime.strptime(game['game_datetime'], '%Y-%m-%dT%H:%M:%SZ') - # Convert to aware UTC object dt_aware = dt_naive.replace(tzinfo=timezone.utc) - # Convert to system local time - local_time = dt_aware.astimezone() - inning_text = local_time.strftime('%-I:%M %p') + return dt_aware.astimezone().strftime('%-I:%M %p') except (ValueError, KeyError, TypeError): - inning_text = "" - else: - away_score = game.get('away_score', "-") - home_score = game.get('home_score', "-") - if away_score is None: - away_score = "-" - if home_score is None: - home_score = "-" - - # Inning data - inning_val = game.get('current_inning', '') - inning_state = game.get('inning_state', '') - if status == 'Final': - inning_text = "FINAL" - elif inning_val: - prefix = "" - if inning_state.lower().startswith('top'): - prefix = "TOP" - elif inning_state.lower().startswith('bottom'): - prefix = "BOT" - inning_text = f"{prefix} {inning_val}" - else: - inning_text = "" - - # Away Team Row - self.away_info = ptg.Splitter( + return "" + + if status == 'Final': + return "FINAL" + + inning_val = game.get('current_inning') + inning_state = game.get('inning_state', '') + if inning_val: + prefix = "" + if inning_state.lower().startswith('top'): + prefix = "TOP" + elif inning_state.lower().startswith('bottom'): + prefix = "BOT" + return f"{prefix} {inning_val}" + + return "" + + def _create_rows(self, away_data, home_data, inning_text): + """Creates the splitter rows for away and home teams.""" + away_abbr, away_score = away_data + home_abbr, home_score = home_data + + away_row = ptg.Splitter( ptg.Label(f"[bold]{away_abbr:3}[/] {away_score:>2}"), ptg.Label("", parent_align=ptg.HorizontalAlignment.RIGHT) ) - # Home Team Row + Status (Right Justified) - # We use a Splitter for the second row to separate Home and Status - self.home_info = ptg.Splitter( + home_row = ptg.Splitter( ptg.Label(f"[bold]{home_abbr:3}[/] {home_score:>2}"), ptg.Label(f"[italic]{inning_text}[/]", parent_align=ptg.HorizontalAlignment.CENTER) ) - - self.set_widgets([self.away_info, self.home_info]) - self.border = ptg.boxes.SINGLE + return away_row, home_row def handle_key(self, key): """ From 23faf50babc057474595c9990269f8502a0f2f80 Mon Sep 17 00:00:00 2001 From: Conor Cleary Date: Sat, 16 May 2026 19:26:29 -0500 Subject: [PATCH 03/11] Start adding full season data to the application --- AGENTS.md | 6 + app/models/data_service.py | 12 +- app/screens/__init__.py | 8 + app/screens/calendar_screen.py | 58 +++ app/screens/main_screens.py | 85 ---- app/screens/schedule_screen.py | 33 ++ app/screens/standings_screen.py | 39 ++ app/widgets/__init__.py | 22 ++ app/widgets/calendar_widget.py | 78 ++++ .../{game_widgets.py => game_widget.py} | 108 +----- app/widgets/navigation_widget.py | 73 ++++ app/widgets/separator.py | 11 + app/widgets/standing_widget.py | 66 ++++ mlb_cli.py | 232 +++++++++-- tests/models/test_data_service.py | 32 +- tests/screens/test_main_screens.py | 79 ++-- tests/test_mlb_cli.py | 365 +++++++++++++++++- tests/widgets/test_game_widgets.py | 75 +++- 18 files changed, 1089 insertions(+), 293 deletions(-) create mode 100644 app/screens/calendar_screen.py delete mode 100644 app/screens/main_screens.py create mode 100644 app/screens/schedule_screen.py create mode 100644 app/screens/standings_screen.py create mode 100644 app/widgets/calendar_widget.py rename app/widgets/{game_widgets.py => game_widget.py} (53%) create mode 100644 app/widgets/navigation_widget.py create mode 100644 app/widgets/separator.py create mode 100644 app/widgets/standing_widget.py diff --git a/AGENTS.md b/AGENTS.md index b3162b5..d6ceee8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,9 @@ +## Development Instructions +To ensure clean code structure and organization: +- All new widget types should be generated in their own file and class within the @app/widgets/ directory. +- All new screens should be generated in their own file and cliss within the @app/screens/ directory. +- Larger functionality requests should be broken down in reusable functions for single pieces of functionality. + ## Testing Instructions - All changes MUST have their style verified. Use pylint as described in @.github/workflows/pylint.yml after making any code changes. - All changes MUST be verified by running pytest as described in the README. All tests must pass and code coverage in @app/ must remain at 100%. diff --git a/app/models/data_service.py b/app/models/data_service.py index df7883b..9d16ee9 100644 --- a/app/models/data_service.py +++ b/app/models/data_service.py @@ -65,13 +65,23 @@ def fetch_schedule(date_str): data = statsapi.schedule(date=date_str) - # Policy: Today has TTL, Yesterday doesn't + # Policy: Today has TTL, other dates don't ttl = LIVE_DATA_TTL if date_str == get_today_date() else None set_cached_data(cache_key, data, ttl=ttl) return data +def format_date(dt): + """Formats a datetime object to MM/DD/YYYY.""" + return dt.strftime('%m/%d/%Y') + + +def parse_date(date_str): + """Parses a MM/DD/YYYY string into a datetime object.""" + return datetime.strptime(date_str, '%m/%d/%Y') + + def get_yesterday_date(): """ Returns yesterday's date as a formatted string. diff --git a/app/screens/__init__.py b/app/screens/__init__.py index e69de29..92eae05 100644 --- a/app/screens/__init__.py +++ b/app/screens/__init__.py @@ -0,0 +1,8 @@ +""" +Screens package for the MLB CLI application. +""" +from .schedule_screen import ScheduleScreen +from .standings_screen import StandingsScreen +from .calendar_screen import CalendarScreen + +__all__ = ["ScheduleScreen", "StandingsScreen", "CalendarScreen"] diff --git a/app/screens/calendar_screen.py b/app/screens/calendar_screen.py new file mode 100644 index 0000000..018610c --- /dev/null +++ b/app/screens/calendar_screen.py @@ -0,0 +1,58 @@ +""" +Calendar screen for the MLB CLI application. +""" +import calendar +import pytermgui as ptg +from app.widgets import NavigationWidget, CalendarWidget + + +class CalendarScreen: + # pylint: disable=too-few-public-methods + """ + Screen class for displaying the MLB calendar. + """ + + @staticmethod + def get_widgets(year, months, on_date_selected, selected_date=None): + """ + Generates the widget list and title for the calendar view showing multiple months. + + Args: + year (int): Year to display. + months (list): List of month integers to display. + on_date_selected (callable): Callback for date selection. + selected_date (datetime, optional): The currently selected date for highlighting. + + Returns: + tuple: (list of widgets, title string) + """ + month_widgets = [] + month_names = [] + + for month in months: + name = calendar.month_name[month] + month_names.append(name) + + selected_day = None + if selected_date and selected_date.year == year and \ + selected_date.month == month: + selected_day = selected_date.day + + container = ptg.Container( + ptg.Label(f"[bold]{name}[/]", parent_align=ptg.HorizontalAlignment.CENTER), + CalendarWidget(year, month, on_date_selected, selected_day=selected_day), + box="EMPTY" + ) + month_widgets.append(container) + + # Fill with empty if less than 3 + while len(month_widgets) < 3: + month_widgets.append(ptg.Label("")) + + widgets = [ + NavigationWidget(active_page="calendar"), + ] + widgets.extend(month_widgets) + + title_range = f"{month_names[0]} - {month_names[-1]}" + return widgets, f"[green]Calendar - {title_range} {year}[/]" diff --git a/app/screens/main_screens.py b/app/screens/main_screens.py deleted file mode 100644 index 665a41a..0000000 --- a/app/screens/main_screens.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -Screen definitions for the MLB CLI application. -Contains functions that return lists of widgets and titles for different application views. -""" -import pytermgui as ptg -from app.widgets.game_widgets import create_grid, StandingWidget, NavigationWidget -from app.models.data_service import ( - fetch_schedule, - get_yesterday_date, - get_today_date, - fetch_standings -) - - -def get_yesterday_widgets(_on_switch_today, _on_switch_standings): - """ - Generates the widget list and title for the 'Yesterday's Scores' screen. - - Args: - _on_switch_today (callable): Callback to switch to the 'Today' screen. - _on_switch_standings (callable): Callback to switch to the 'Standings' screen. - - Returns: - tuple: (list of widgets, title string) - """ - date_str = get_yesterday_date() - games = fetch_schedule(date_str) - - widgets = [ - NavigationWidget(active_page="yesterday"), - ptg.Label(f"[bold]MLB Scores - {date_str}[/]"), - *create_grid(games), - ] - return widgets, "[green]Yesterday's Scores[/]" - - -def get_today_widgets(_on_switch_yesterday, _on_switch_standings): - """ - Generates the widget list and title for the 'Today's Schedule' screen. - - Args: - _on_switch_yesterday (callable): Callback to switch to the 'Yesterday' screen. - _on_switch_standings (callable): Callback to switch to the 'Standings' screen. - - Returns: - tuple: (list of widgets, title string) - """ - date_str = get_today_date() - games = fetch_schedule(date_str) - - widgets = [ - NavigationWidget(active_page="today"), - ptg.Label(f"[bold]MLB Schedule - {date_str}[/]"), - *create_grid(games), - ] - return widgets, "[green]Today's Schedule[/]" - - -def get_standings_widgets(_on_switch_yesterday): - """ - Generates the widget list and title for the 'MLB Standings' screen. - - Args: - _on_switch_yesterday (callable): Callback to return to the scores view. - - Returns: - tuple: (list of widgets, title string) - """ - al_divs, nl_divs, al_wc, nl_wc = fetch_standings() - - # Define row structure with weights to ensure uniform width - # 2 columns for divisions, centered 1 column (using padding) for Wild Cards - widgets = [ - NavigationWidget(active_page="standings"), - ptg.Label("[bold]MLB Standings[/]"), - # East Row - ptg.Splitter(StandingWidget(al_divs[0]), StandingWidget(nl_divs[0])), - # Central Row - ptg.Splitter(StandingWidget(al_divs[1]), StandingWidget(nl_divs[1])), - # West Row - ptg.Splitter(StandingWidget(al_divs[2]), StandingWidget(nl_divs[2])), - # Wild Card Row (AL and NL) - ptg.Splitter(StandingWidget(al_wc), StandingWidget(nl_wc)), - ] - return widgets, "[green]MLB Standings[/]" diff --git a/app/screens/schedule_screen.py b/app/screens/schedule_screen.py new file mode 100644 index 0000000..60ea9db --- /dev/null +++ b/app/screens/schedule_screen.py @@ -0,0 +1,33 @@ +""" +Schedule screen for the MLB CLI application. +""" +import pytermgui as ptg +from app.widgets import create_grid, NavigationWidget +from app.models.data_service import fetch_schedule + + +class ScheduleScreen: + # pylint: disable=too-few-public-methods + """ + Screen class for displaying the MLB schedule. + """ + + @staticmethod + def get_widgets(date_str): + """ + Generates the widget list and title for a specific date's schedule. + + Args: + date_str (str): The date string in MM/DD/YYYY format. + + Returns: + tuple: (list of widgets, title string) + """ + games = fetch_schedule(date_str) + + widgets = [ + NavigationWidget(active_page="schedule"), + ptg.Label(f"[bold]MLB Schedule - {date_str}[/]"), + *create_grid(games), + ] + return widgets, f"[green]MLB Schedule - {date_str}[/]" diff --git a/app/screens/standings_screen.py b/app/screens/standings_screen.py new file mode 100644 index 0000000..b5d0993 --- /dev/null +++ b/app/screens/standings_screen.py @@ -0,0 +1,39 @@ +""" +Standings screen for the MLB CLI application. +""" +import pytermgui as ptg +from app.widgets import StandingWidget, NavigationWidget +from app.models.data_service import fetch_standings + + +class StandingsScreen: + # pylint: disable=too-few-public-methods + """ + Screen class for displaying the MLB standings. + """ + + @staticmethod + def get_widgets(): + """ + Generates the widget list and title for the 'MLB Standings' screen. + + Returns: + tuple: (list of widgets, title string) + """ + al_divs, nl_divs, al_wc, nl_wc = fetch_standings() + + # Define row structure with weights to ensure uniform width + # 2 columns for divisions, centered 1 column (using padding) for Wild Cards + widgets = [ + NavigationWidget(active_page="standings"), + ptg.Label("[bold]MLB Standings[/]"), + # East Row + ptg.Splitter(StandingWidget(al_divs[0]), StandingWidget(nl_divs[0])), + # Central Row + ptg.Splitter(StandingWidget(al_divs[1]), StandingWidget(nl_divs[1])), + # West Row + ptg.Splitter(StandingWidget(al_divs[2]), StandingWidget(nl_divs[2])), + # Wild Card Row (AL and NL) + ptg.Splitter(StandingWidget(al_wc), StandingWidget(nl_wc)), + ] + return widgets, "[green]MLB Standings[/]" diff --git a/app/widgets/__init__.py b/app/widgets/__init__.py index e69de29..3624269 100644 --- a/app/widgets/__init__.py +++ b/app/widgets/__init__.py @@ -0,0 +1,22 @@ +""" +Widget package for the MLB CLI application. +Contains various UI components for games, standings, navigation, and more. +""" +from .separator import Separator +from .game_widget import GameWidget, create_grid, chunk_list +from .standing_widget import StandingWidget +from .navigation_widget import NavigationWidget +from .calendar_widget import CalendarWidget, CalendarButton +from .animations import slide_transition + +__all__ = [ + "Separator", + "GameWidget", + "create_grid", + "chunk_list", + "StandingWidget", + "NavigationWidget", + "CalendarWidget", + "CalendarButton", + "slide_transition", +] diff --git a/app/widgets/calendar_widget.py b/app/widgets/calendar_widget.py new file mode 100644 index 0000000..dffbb92 --- /dev/null +++ b/app/widgets/calendar_widget.py @@ -0,0 +1,78 @@ +""" +Calendar widget for the MLB CLI application. +""" +import calendar +import pytermgui as ptg +from .separator import Separator + + +class CalendarButton(ptg.Button): + """ + A button that only responds to keyboard events. + """ + + def handle_mouse(self, event): + """Ignores mouse events.""" + return False + + +class CalendarWidget(ptg.Container): + """ + A widget that displays a calendar for a specific month. + Allows selecting a date and confirming with ENTER. + """ + + def __init__(self, year, month, on_date_selected, selected_day=None, **kwargs): + """ + Initializes the CalendarWidget. + + Args: + year (int): The year to display. + month (int): The month to display. + on_date_selected (callable): Callback when a date is selected with ENTER. + selected_day (int, optional): The day to highlight as selected. + **kwargs: Additional arguments for ptg.Container. + """ + super().__init__(**kwargs) + self.year = year + self.month = month + self.on_date_selected = on_date_selected + self.day_to_button = {} + self.button_to_day = {} + self.border = ptg.boxes.SINGLE + + cal = calendar.Calendar(firstweekday=6) # Sunday + month_days = cal.monthdayscalendar(year, month) + + widgets = [] + # Header: Su Mo Tu We Th Fr Sa + days_header = ptg.Splitter( + *[ptg.Label(d, parent_align=ptg.HorizontalAlignment.CENTER) + for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]] + ) + widgets.append(days_header) + widgets.append(Separator()) + + for week in month_days: + week_widgets = [] + for day in week: + if day == 0: + week_widgets.append(ptg.Label("")) + else: + btn = CalendarButton( + str(day), + lambda b, d=day: self.on_date_selected(self.year, self.month, d) + ) + if day == selected_day: + btn.styles.label = "inverse" + else: + btn.styles.label = "white" + + btn.styles.highlight = "inverse" + + self.day_to_button[day] = btn + self.button_to_day[btn] = day + week_widgets.append(btn) + widgets.append(ptg.Splitter(*week_widgets)) + + self.set_widgets(widgets) diff --git a/app/widgets/game_widgets.py b/app/widgets/game_widget.py similarity index 53% rename from app/widgets/game_widgets.py rename to app/widgets/game_widget.py index 7ef5c86..691d706 100644 --- a/app/widgets/game_widgets.py +++ b/app/widgets/game_widget.py @@ -1,19 +1,11 @@ """ -Custom widgets and UI components for the MLB CLI application. -Includes widgets for games, standings, navigation, and general layout. +Game widget for the MLB CLI application. """ from datetime import datetime, timezone import pytermgui as ptg from app.models.data_service import get_team_abbr -class Separator(ptg.Label): - """A simple horizontal line separator widget.""" - - def __init__(self, **kwargs): - super().__init__("─" * 10, **kwargs) - - class GameWidget(ptg.Container): """ A container widget displaying the score and teams for a single MLB game. @@ -111,104 +103,6 @@ def handle_key(self, key): return super().handle_key(key) -class StandingWidget(ptg.Container): - """ - A container widget displaying the standings for a specific MLB division. - """ - - def __init__(self, division_data, **kwargs): - """ - Initializes the StandingWidget with division data. - - Args: - division_data (dict): Dictionary containing division name and team records. - **kwargs: Additional arguments for ptg.Container. - """ - super().__init__(**kwargs) - if not division_data: - self.inner_widgets = [ptg.Label("No Data")] - self.set_widgets(self.inner_widgets) - return - - self._selectables_length = 1 - self.border = ptg.boxes.SINGLE - - name = division_data.get('div_name', 'Unknown') - # Replace full league names with abbreviations - name = name.replace( - "American League", - "AL").replace( - "National League", - "NL") - - widgets = [ptg.Label(f"[bold]{name}[/]")] - widgets.append(self._create_header()) - - for team in division_data.get('teams', []): - widgets.append(self._create_team_row(team)) - - self.set_widgets(widgets) - self.inner_widgets = widgets - - def _create_header(self): - """Creates the header row for the standings.""" - tm_lbl = "TM".ljust(4) - w_lbl = "W".rjust(3) - l_lbl = "L".rjust(3) - gb_lbl = "GB".rjust(4) - pct_lbl = "PCT".rjust(6) - l10_lbl = "L10".rjust(6) - return ptg.Label(f"[bold]{tm_lbl} {w_lbl} {l_lbl} {gb_lbl} {pct_lbl} {l10_lbl}[/]") - - def _create_team_row(self, team): - """Creates a data row for a single team.""" - # Use team abbreviation instead of full name - abbr = get_team_abbr(team['team_id']).ljust(4) - w = str(team['w']).rjust(3) - l = str(team['l']).rjust(3) - gb = str(team['gb']).rjust(4) - pct = str(team['pct']).rjust(6) - l10 = str(team['l10']).rjust(6) - return ptg.Label(f"{abbr} {w} {l} {gb} {pct} {l10}") - - -class NavigationWidget(ptg.Container): - """ - A persistent navigation bar widget shown at the top of every screen. - Displays available pages and their hotkeys, highlighting the active one. - """ - - def __init__(self, active_page=None, **kwargs): - """ - Initializes the NavigationWidget. - - Args: - active_page (str, optional): The name of the currently active screen. - **kwargs: Additional arguments for ptg.Container. - """ - super().__init__(**kwargs) - self.border = ptg.boxes.EMPTY - - yest_style = "inverse" if active_page == "yesterday" else "" - today_style = "inverse" if active_page == "today" else "" - stand_style = "inverse" if active_page == "standings" else "" - - self.yest_label = ptg.Label(f"[{yest_style}]Yesterday[/] [bold][cyan][[/]", - parent_align=ptg.HorizontalAlignment.CENTER) - self.today_label = ptg.Label(f"[{today_style}]Today[/] [bold][cyan]][/]", - parent_align=ptg.HorizontalAlignment.CENTER) - self.stand_label = ptg.Label(f"[{stand_style}]Standings[/] [bold][cyan]s[/]", - parent_align=ptg.HorizontalAlignment.CENTER) - - self.splitter = ptg.Splitter( - self.yest_label, - self.today_label, - self.stand_label, - ) - - self.set_widgets([self.splitter]) - - def chunk_list(lst, n): """ Yields successive n-sized chunks from a list. diff --git a/app/widgets/navigation_widget.py b/app/widgets/navigation_widget.py new file mode 100644 index 0000000..0be898f --- /dev/null +++ b/app/widgets/navigation_widget.py @@ -0,0 +1,73 @@ +""" +Navigation widget for the MLB CLI application. +""" +import pytermgui as ptg + + +class NavigationWidget(ptg.Container): + """ + A persistent navigation bar widget shown at the top of every screen. + Displays available pages and their hotkeys, highlighting the active one. + """ + # pylint: disable=too-many-instance-attributes + + def __init__(self, active_page=None, **kwargs): + """ + Initializes the NavigationWidget. + + Args: + active_page (str, optional): The name of the currently active screen. + **kwargs: Additional arguments for ptg.Container. + """ + super().__init__(**kwargs) + self.border = ptg.boxes.EMPTY + + if active_page == "standings": + self.schedule_label = ptg.Label( + "Back to Schedule [bold][cyan]x[/]", + parent_align=ptg.HorizontalAlignment.CENTER) + self.calendar_label = ptg.Label( + "Back to Calendar [bold][cyan]c[/]", + parent_align=ptg.HorizontalAlignment.CENTER) + self.set_widgets([ptg.Splitter(self.schedule_label, self.calendar_label)]) + return + + if active_page == "calendar": + self.prev_label = ptg.Label( + "Prev Page [bold][cyan][[/]", + parent_align=ptg.HorizontalAlignment.CENTER) + self.next_label = ptg.Label( + "Next Page [bold][cyan]][/]", + parent_align=ptg.HorizontalAlignment.CENTER) + self.stand_label = ptg.Label( + "Standings [bold][cyan]x[/]", + parent_align=ptg.HorizontalAlignment.CENTER) + self.set_widgets([ptg.Splitter(self.prev_label, self.next_label, self.stand_label)]) + return + + # Default: Schedule page navigation + self.prev_label = ptg.Label( + "Prev [bold][cyan][[/]", + parent_align=ptg.HorizontalAlignment.CENTER) + self.next_label = ptg.Label( + "Next [bold][cyan]][/]", + parent_align=ptg.HorizontalAlignment.CENTER) + self.today_label = ptg.Label( + "Today [bold][cyan]t[/]", + parent_align=ptg.HorizontalAlignment.CENTER) + self.cal_label = ptg.Label( + "Calendar [bold][cyan]c[/]", + parent_align=ptg.HorizontalAlignment.CENTER) + self.stand_label = ptg.Label( + "Standings [bold][cyan]x[/]", + parent_align=ptg.HorizontalAlignment.CENTER) + + self.splitter = ptg.Splitter( + self.prev_label, + self.next_label, + self.today_label, + self.cal_label, + self.stand_label, + ) + + self.set_widgets([self.splitter]) diff --git a/app/widgets/separator.py b/app/widgets/separator.py new file mode 100644 index 0000000..3d25b8b --- /dev/null +++ b/app/widgets/separator.py @@ -0,0 +1,11 @@ +""" +Separator widget for the MLB CLI application. +""" +import pytermgui as ptg + + +class Separator(ptg.Label): + """A simple horizontal line separator widget.""" + + def __init__(self, **kwargs): + super().__init__("─" * 10, **kwargs) diff --git a/app/widgets/standing_widget.py b/app/widgets/standing_widget.py new file mode 100644 index 0000000..c29ad16 --- /dev/null +++ b/app/widgets/standing_widget.py @@ -0,0 +1,66 @@ +""" +Standing widget for the MLB CLI application. +""" +import pytermgui as ptg +from app.models.data_service import get_team_abbr + + +class StandingWidget(ptg.Container): + """ + A container widget displaying the standings for a specific MLB division. + """ + + def __init__(self, division_data, **kwargs): + """ + Initializes the StandingWidget with division data. + + Args: + division_data (dict): Dictionary containing division name and team records. + **kwargs: Additional arguments for ptg.Container. + """ + super().__init__(**kwargs) + if not division_data: + self.inner_widgets = [ptg.Label("No Data")] + self.set_widgets(self.inner_widgets) + return + + self._selectables_length = 1 + self.border = ptg.boxes.SINGLE + + name = division_data.get('div_name', 'Unknown') + # Replace full league names with abbreviations + name = name.replace( + "American League", + "AL").replace( + "National League", + "NL") + + widgets = [ptg.Label(f"[bold]{name}[/]")] + widgets.append(self._create_header()) + + for team in division_data.get('teams', []): + widgets.append(self._create_team_row(team)) + + self.set_widgets(widgets) + self.inner_widgets = widgets + + def _create_header(self): + """Creates the header row for the standings.""" + tm_lbl = "TM".ljust(4) + w_lbl = "W".rjust(3) + l_lbl = "L".rjust(3) + gb_lbl = "GB".rjust(4) + pct_lbl = "PCT".rjust(6) + l10_lbl = "L10".rjust(6) + return ptg.Label(f"[bold]{tm_lbl} {w_lbl} {l_lbl} {gb_lbl} {pct_lbl} {l10_lbl}[/]") + + def _create_team_row(self, team): + """Creates a data row for a single team.""" + # Use team abbreviation instead of full name + abbr = get_team_abbr(team['team_id']).ljust(4) + w = str(team['w']).rjust(3) + l = str(team['l']).rjust(3) + gb = str(team['gb']).rjust(4) + pct = str(team['pct']).rjust(6) + l10 = str(team['l10']).rjust(6) + return ptg.Label(f"{abbr} {w} {l} {gb} {pct} {l10}") diff --git a/mlb_cli.py b/mlb_cli.py index 30237a9..6da2060 100644 --- a/mlb_cli.py +++ b/mlb_cli.py @@ -2,20 +2,25 @@ Main entry point for the MLB CLI application. This module initializes the Terminal UI and manages the primary window and global keybindings. """ +from datetime import datetime, timedelta import pytermgui as ptg -from app.models.data_service import fetch_teams -from app.screens.main_screens import ( - get_yesterday_widgets, - get_today_widgets, - get_standings_widgets +from app.models.data_service import ( + fetch_teams, + format_date ) -from app.widgets.animations import slide_transition +from app.screens import ( + ScheduleScreen, + StandingsScreen, + CalendarScreen +) +from app.widgets import CalendarWidget, slide_transition class MLBApp: """ Main application class that manages the WindowManager and screen transitions. """ + # pylint: disable=too-many-instance-attributes def __init__(self): """Initializes the application state and UI components.""" @@ -26,6 +31,21 @@ def __init__(self): 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): """Sets content for the main window, using animation if already initialized.""" @@ -46,26 +66,182 @@ def set_window_data(self, widgets, title, page_name): slide_transition(self.main_window, self.manager, widgets, title) - def update_to_yesterday(self, *_args, **_kwargs): - """Transitions the main window to show yesterday's scores.""" - widgets, title = get_yesterday_widgets( - self.update_to_today, self.update_to_standings) - self.set_window_data(widgets, title, "yesterday") + 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) + widgets, title = ScheduleScreen.get_widgets(date_str) + self.set_window_data(widgets, title, f"schedule:{date_str}") return True - def update_to_today(self, *_args, **_kwargs): - """Transitions the main window to show today's schedule.""" - widgets, title = get_today_widgets( - self.update_to_yesterday, self.update_to_standings) - self.set_window_data(widgets, title, "today") + def go_to_previous_day(self, *_args, **_kwargs): + """Decrements the current date or page.""" + if self.active_page and self.active_page.startswith("calendar"): + 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) + 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"): + 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) + return self.update_to_schedule() return True - def update_to_standings(self, *_args, **_kwargs): - """Transitions the main window to show current MLB standings.""" - widgets, title = get_standings_widgets(self.update_to_yesterday) + def toggle_standings(self, *_args, **_kwargs): + """Toggles between standings and schedule/calendar.""" + if self.active_page == "standings": + return self.update_to_schedule() + + widgets, title = StandingsScreen.get_widgets() self.set_window_data(widgets, title, "standings") return True + def update_to_calendar(self, *_args, **_kwargs): + """Transitions to the calendar view.""" + if self.active_page == "standings": + self.current_date = datetime.now() + + # Ensure calendar_page matches current_date + self._determine_initial_calendar_page() + + pages = [ + [3, 4, 5], + [6, 7, 8], + [9, 10] + ] + months = pages[self.calendar_page] + widgets, title = CalendarScreen.get_widgets( + 2026, + months, + self.on_calendar_date_selected, + selected_date=self.current_date + ) + self.set_window_data(widgets, title, f"calendar:{self.calendar_page}") + + # Focus current date if it's on this page + self._focus_current_date_in_calendar() + return True + + def _focus_current_date_in_calendar(self): + """Helper to find and focus current_date in the calendar view.""" + for widget in self.main_window: + if not isinstance(widget, ptg.Container): + continue + for sub in widget: + if not (isinstance(sub, CalendarWidget) and + sub.month == self.current_date.month): + continue + day = self.current_date.day + if day not in sub.day_to_button: + continue + btn = sub.day_to_button[day] + for i, (selectable, _) in enumerate(self.main_window.selectables): + if selectable is btn: + self.main_window.select(i) + self.manager.focused = btn + return True + return False + + 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) + return self.update_to_schedule() + + def _navigate_calendar(self, direction): + # pylint: disable=too-many-branches + """ + 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")): + return False + + focused = self.manager.focused + if focused is None: + return False + + # Find which CalendarWidget the focused button belongs to + target_widget = None + for widget in self.main_window: + if not isinstance(widget, ptg.Container): + continue + for sub in widget: + if isinstance(sub, CalendarWidget) and focused in sub.button_to_day: + target_widget = sub + break + if target_widget: + break + + if not target_widget: + return False + + day = target_widget.button_to_day[focused] + current_date = datetime(target_widget.year, target_widget.month, day) + + delta = { + "w": timedelta(days=-7), + "a": timedelta(days=-1), + "s": timedelta(days=7), + "d": timedelta(days=1), + }.get(direction) + + if not delta: + return False + + target_date = current_date + delta + + # Look for the target date in any CalendarWidget in the main window + for widget in self.main_window: + if not isinstance(widget, ptg.Container): + continue + for sub in widget: + if not (isinstance(sub, CalendarWidget) and + sub.year == target_date.year and + sub.month == target_date.month): + continue + + if target_date.day in sub.day_to_button: + target_btn = sub.day_to_button[target_date.day] + # Find and select the button index in the window + for i, (selectable, _) in enumerate(self.main_window.selectables): + if selectable is target_btn: + self.main_window.select(i) + self.manager.focused = target_btn + return True + return False + + def go_to_previous_page(self, *_args, **_kwargs): + """Moves calendar view to the previous page.""" + if self.calendar_page > 0: + self.calendar_page -= 1 + return self.update_to_calendar() + return True + + def go_to_next_page(self, *_args, **_kwargs): + """Moves calendar view to the next page.""" + if self.calendar_page < 2: + self.calendar_page += 1 + return self.update_to_calendar() + return True + + def go_to_today(self, *_args, **_kwargs): + """Resets the current date to today and updates the view.""" + self.current_date = datetime.now() + return self.update_to_schedule() + def exit_app(self, *_args, **_kwargs): """Stops the WindowManager and exits the application.""" self.manager.stop() @@ -83,15 +259,23 @@ def run(self): is_noresize=True) self.manager.add(self.main_window) - # Initial content - self.update_to_yesterday() + # Initial content: Calendar + self.update_to_calendar() # Global bindings - self.manager.bind("[", self.update_to_yesterday) - self.manager.bind("]", self.update_to_today) - self.manager.bind("s", self.update_to_standings) + self.manager.bind("[", self.go_to_previous_day) + self.manager.bind("]", self.go_to_next_day) + self.manager.bind("t", self.go_to_today) + self.manager.bind("c", self.update_to_calendar) + self.manager.bind("x", self.toggle_standings) self.manager.bind(ptg.keys.ESC, self.exit_app) + # WASD for Calendar Navigation + self.manager.bind("w", lambda *_: self._navigate_calendar("w")) + self.manager.bind("a", lambda *_: self._navigate_calendar("a")) + self.manager.bind("s", lambda *_: self._navigate_calendar("s")) + self.manager.bind("d", lambda *_: self._navigate_calendar("d")) + self.manager.run() diff --git a/tests/models/test_data_service.py b/tests/models/test_data_service.py index 75c4610..9292313 100644 --- a/tests/models/test_data_service.py +++ b/tests/models/test_data_service.py @@ -12,6 +12,7 @@ get_today_date, fetch_wild_card, fetch_standings, + parse_date, TEAMS ) @@ -86,28 +87,32 @@ def test_fetch_schedule_cache_hit(self, mock_cache): @patch('app.models.data_service.set_cached_data') @patch('app.models.data_service.get_cached_data') @patch('statsapi.schedule') - def test_fetch_schedule_cache_miss_today(self, mock_schedule, mock_get_cache, mock_set_cache): + @patch('app.models.data_service.datetime') + def test_fetch_schedule_cache_miss_today(self, mock_datetime, mock_schedule, + mock_get_cache, mock_set_cache): """Test fetch_schedule sets TTL for today's schedule.""" mock_get_cache.return_value = None mock_schedule.return_value = [{'id': 1}] + mock_datetime.now.return_value = datetime(2024, 1, 1) - with patch('app.models.data_service.get_today_date', return_value='01/01/2024'): - fetch_schedule('01/01/2024') - mock_set_cache.assert_called_with( - 'schedule:01/01/2024', [{'id': 1}], ttl=300 - ) + fetch_schedule('01/01/2024') + mock_set_cache.assert_called_with( + 'schedule:01/01/2024', [{'id': 1}], ttl=300 + ) @patch('app.models.data_service.set_cached_data') @patch('app.models.data_service.get_cached_data') @patch('statsapi.schedule') - def test_fetch_schedule_cache_miss_yesterday(self, mock_sched, mock_get_c, mock_set_c): + @patch('app.models.data_service.datetime') + def test_fetch_schedule_cache_miss_yesterday(self, mock_datetime, mock_sched, + mock_get_c, mock_set_c): """Test fetch_schedule uses no TTL for other dates (yesterday).""" mock_get_c.return_value = None mock_sched.return_value = [{'id': 1}] + mock_datetime.now.return_value = datetime(2024, 1, 2) - with patch('app.models.data_service.get_today_date', return_value='01/02/2024'): - fetch_schedule('01/01/2024') - mock_set_c.assert_called_with('schedule:01/01/2024', [{'id': 1}], ttl=None) + fetch_schedule('01/01/2024') + mock_set_c.assert_called_with('schedule:01/01/2024', [{'id': 1}], ttl=None) @patch('app.models.data_service.datetime') def test_get_today_date(self, mock_datetime): @@ -121,6 +126,13 @@ def test_get_yesterday_date(self, mock_datetime): mock_datetime.now.return_value = datetime(2024, 1, 2) self.assertEqual(get_yesterday_date(), '01/01/2024') + def test_parse_date(self): + """Test parse_date utility.""" + dt = parse_date('05/15/2026') + self.assertEqual(dt.year, 2026) + self.assertEqual(dt.month, 5) + self.assertEqual(dt.day, 15) + @patch('app.models.data_service.get_cached_data') @patch('statsapi.get') def test_fetch_wild_card_cache_hit(self, _mock_get, mock_cache): diff --git a/tests/screens/test_main_screens.py b/tests/screens/test_main_screens.py index 860c9d9..0ff3d97 100644 --- a/tests/screens/test_main_screens.py +++ b/tests/screens/test_main_screens.py @@ -3,46 +3,29 @@ """ import unittest from unittest.mock import patch, MagicMock -from app.screens.main_screens import ( - get_yesterday_widgets, - get_today_widgets, - get_standings_widgets -) +from datetime import datetime +import pytermgui as ptg +from app.screens import ScheduleScreen, StandingsScreen, CalendarScreen +from app.widgets import CalendarWidget class TestMainScreens(unittest.TestCase): """Test cases for main_screens.py screen generator functions.""" - @patch('app.screens.main_screens.get_yesterday_date') - @patch('app.screens.main_screens.fetch_schedule') - @patch('app.screens.main_screens.create_grid') - def test_get_yesterday_widgets(self, mock_grid, mock_fetch, mock_date): - """Test yesterday screen generation.""" - mock_date.return_value = '01/01/2024' + @patch('app.screens.schedule_screen.fetch_schedule') + @patch('app.screens.schedule_screen.create_grid') + def test_get_schedule_widgets(self, mock_grid, mock_fetch): + """Test schedule screen generation.""" + date_str = '01/01/2026' mock_fetch.return_value = [] mock_grid.return_value = [] - widgets, title = get_yesterday_widgets(MagicMock(), MagicMock()) + widgets, title = ScheduleScreen.get_widgets(date_str) - self.assertIn("Yesterday's Scores", title) + self.assertIn(date_str, title) self.assertEqual(len(widgets), 2) # NavigationWidget and Label - mock_fetch.assert_called_with('01/01/2024') + mock_fetch.assert_called_with(date_str) - @patch('app.screens.main_screens.get_today_date') - @patch('app.screens.main_screens.fetch_schedule') - @patch('app.screens.main_screens.create_grid') - def test_get_today_widgets(self, mock_grid, mock_fetch, mock_date): - """Test today screen generation.""" - mock_date.return_value = '01/02/2024' - mock_fetch.return_value = [] - mock_grid.return_value = [] - - widgets, title = get_today_widgets(MagicMock(), MagicMock()) - - self.assertIn("Today's Schedule", title) - self.assertEqual(len(widgets), 2) - mock_fetch.assert_called_with('01/02/2024') - - @patch('app.screens.main_screens.fetch_standings') + @patch('app.screens.standings_screen.fetch_standings') def test_get_standings_widgets(self, mock_fetch): """Test standings screen generation.""" # Mock 3 divisions for AL and NL @@ -58,12 +41,46 @@ def test_get_standings_widgets(self, mock_fetch): ] mock_fetch.return_value = (al_divs, nl_divs, {}, {}) - widgets, title = get_standings_widgets(MagicMock()) + widgets, title = StandingsScreen.get_widgets() self.assertIn("MLB Standings", title) # Nav, Label, 4 Division/WC Splitters self.assertEqual(len(widgets), 6) mock_fetch.assert_called_once() + def test_get_calendar_widgets(self): + """Test calendar screen generation.""" + mock_on_selected = MagicMock() + widgets, title = CalendarScreen.get_widgets(2026, [3, 4, 5], mock_on_selected) + + self.assertIn("March - May 2026", title) + # Nav + 3 month containers + self.assertEqual(len(widgets), 4) + + def test_get_calendar_widgets_selected_date(self): + """Test calendar screen generation with a selected date highlighted.""" + mock_on_selected = MagicMock() + selected_date = datetime(2026, 4, 15) + widgets, _ = CalendarScreen.get_widgets(2026, [3, 4, 5], mock_on_selected, selected_date=selected_date) + + # Check if the correct month container received the selected day + # widgets[1] is March, widgets[2] is April, widgets[3] is May + april_container = widgets[2] + # April container has a Label and a CalendarWidget + cal_widget = [w for w in april_container if isinstance(w, CalendarWidget)][0] + # We can't easily check private state but we know line 39 was hit + self.assertEqual(cal_widget.month, 4) + + def test_get_calendar_widgets_padding(self): + """Test calendar screen generation with padding for fewer than 3 months.""" + mock_on_selected = MagicMock() + # Page 3 has 2 months (Sep, Oct) + widgets, _ = CalendarScreen.get_widgets(2026, [9, 10], mock_on_selected) + + # Nav + 2 month containers + 1 padding label = 4 widgets + self.assertEqual(len(widgets), 4) + self.assertIsInstance(widgets[3], ptg.Label) + self.assertEqual(widgets[3].value, "") + if __name__ == '__main__': # pragma: no cover unittest.main() diff --git a/tests/test_mlb_cli.py b/tests/test_mlb_cli.py index d891d39..d804f5d 100644 --- a/tests/test_mlb_cli.py +++ b/tests/test_mlb_cli.py @@ -3,11 +3,14 @@ """ import unittest from unittest.mock import patch, MagicMock +from datetime import datetime, timedelta import pytermgui as ptg from mlb_cli import MLBApp, main +from app.widgets import CalendarWidget class TestMLBApp(unittest.TestCase): """Test cases for the MLBApp class.""" + # pylint: disable=too-many-public-methods,protected-access def setUp(self): """Initialize MLBApp with mocked WindowManager.""" @@ -16,6 +19,7 @@ def setUp(self): self.app = MLBApp() self.app.manager = mock_manager.return_value self.app.main_window = MagicMock(spec=ptg.Window) + self.app.main_window.__iter__.return_value = iter([]) def test_init(self): """Test MLBApp initialization.""" @@ -36,8 +40,61 @@ def test_set_window_data_initial(self): 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.assertEqual(self.app.active_page, "page1") + self.app.main_window.set_widgets.assert_not_called() + + 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_container = MagicMock(spec=ptg.Container) + mock_container.__iter__.return_value = iter([mock_cal]) + + self.app.main_window.__iter__.return_value = iter([mock_container]) + self.app.main_window.selectables = [(mock_btn, 0)] + + self.assertTrue(self.app._focus_current_date_in_calendar()) + 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): @@ -51,27 +108,295 @@ def test_set_window_data_transition(self, mock_transition): self.assertEqual(self.app.active_page, "page2") mock_transition.assert_called_once() - @patch('mlb_cli.get_yesterday_widgets') - def test_update_to_yesterday(self, mock_get): - """Test transition to yesterday's scores.""" - mock_get.return_value = ([], "Yesterday") - self.app.update_to_yesterday() - self.assertEqual(self.app.active_page, "yesterday") - - @patch('mlb_cli.get_today_widgets') - def test_update_to_today(self, mock_get): - """Test transition to today's schedule.""" - mock_get.return_value = ([], "Today") - self.app.update_to_today() - self.assertEqual(self.app.active_page, "today") - - @patch('mlb_cli.get_standings_widgets') - def test_update_to_standings(self, mock_get): - """Test transition to standings.""" + 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.""" + self.app.calendar_page = 0 + self.assertTrue(self.app.go_to_previous_page()) + self.assertEqual(self.app.calendar_page, 0) + + def test_go_to_next_page_boundary(self): + """Test boundary for go_to_next_page.""" + self.app.calendar_page = 2 + self.assertTrue(self.app.go_to_next_page()) + self.assertEqual(self.app.calendar_page, 2) + + 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.day_to_button = {} # Day missing + + mock_container = MagicMock(spec=ptg.Container) + mock_container.__iter__.return_value = iter([mock_cal]) + + self.app.main_window.__iter__.return_value = iter([mock_container]) + self.assertFalse(self.app._focus_current_date_in_calendar()) + + 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_container = MagicMock(spec=ptg.Container) + mock_container.__iter__.return_value = iter([mock_cal]) + + 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") + self.app.update_to_calendar() + self.assertEqual(self.app.active_page, f"calendar:{self.app.calendar_page}") + + @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.update_to_calendar() + + # Should be today + self.assertEqual( + self.app.current_date.strftime('%Y-%m-%d'), + datetime.now().strftime('%Y-%m-%d') + ) + + def test_go_to_previous_page(self): + """Test decrementing calendar page.""" + self.app.calendar_page = 1 + with patch.object(self.app, 'update_to_calendar'): + self.app.go_to_previous_page() + self.assertEqual(self.app.calendar_page, 0) + + def test_go_to_next_page(self): + """Test incrementing calendar page.""" + self.app.calendar_page = 1 + with patch.object(self.app, 'update_to_calendar'): + self.app.go_to_next_page() + self.assertEqual(self.app.calendar_page, 2) + + 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) + + def test_navigate_calendar_state_checks(self): + """Test _navigate_calendar basic state checks.""" + # 1. Not on calendar page + self.app.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.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" + + # Create CalendarWidget with day 1 and day 2 + mock_on_selected = MagicMock() + cal = CalendarWidget(2026, 5, mock_on_selected) + btn1 = cal.day_to_button[1] + btn2 = cal.day_to_button[2] + + # Put button 1 in focus + self.app.manager.focused = btn1 + + # Real Container with CalendarWidget + real_container = ptg.Container(cal) + + self.app.main_window.__iter__.side_effect = lambda: iter([real_container]) + # selectables: (button, index) + self.app.main_window.selectables = [(btn1, 0), (btn2, 1)] + + # Move right ('d') from day 1 to day 2 + self.assertTrue(self.app._navigate_calendar("d")) + self.app.main_window.select.assert_called_with(1) + self.assertEqual(self.app.manager.focused, btn2) + + def test_navigate_calendar_invalid_direction(self): + """Test _navigate_calendar with invalid direction.""" + self.app.active_page = "calendar:0" + cal = CalendarWidget(2026, 5, MagicMock()) + btn1 = cal.day_to_button[1] + self.app.manager.focused = btn1 + + real_container = ptg.Container(cal) + self.app.main_window.__iter__.side_effect = lambda: iter([real_container]) + + self.assertFalse(self.app._navigate_calendar("z")) + + def test_navigate_calendar_target_not_found(self): + """Test _navigate_calendar when target button is not in view.""" + self.app.active_page = "calendar:0" + cal = CalendarWidget(2026, 5, MagicMock()) + btn31 = cal.day_to_button[31] # Last day of May + self.app.manager.focused = btn31 + + real_container = ptg.Container(cal) + self.app.main_window.__iter__.side_effect = lambda: iter([real_container]) + + # Move right ('d') from May 31 should look for June 1, which isn't in May 2026 widget + self.assertFalse(self.app._navigate_calendar("d")) + + def test_navigate_calendar_not_a_container(self): + """Test _navigate_calendar skips non-container widgets in both loops.""" + self.app.active_page = "calendar:0" + + cal = CalendarWidget(2026, 5, MagicMock()) + btn1 = cal.day_to_button[1] + self.app.manager.focused = btn1 + + real_container = ptg.Container(cal) + # Yield a Label THEN a Container to hit first loop's continue + # Then yield a Label THEN a Container to hit second loop's continue + self.app.main_window.__iter__.side_effect = [ + iter([ptg.Label("test"), real_container]), # First loop + iter([ptg.Label("test"), real_container]) # Second loop + ] + + btn2 = cal.day_to_button[2] + self.app.main_window.selectables = [(btn1, 0), (btn2, 1)] + + self.assertTrue(self.app._navigate_calendar("d")) + + 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" + mock_btn = MagicMock(spec=ptg.Button) + self.app.manager.focused = mock_btn + + # Container with a non-calendar widget + real_container = ptg.Container(ptg.Label("test")) + + self.app.main_window.__iter__.side_effect = lambda: iter([real_container]) + self.assertFalse(self.app._navigate_calendar("d")) + + 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" + + cal = CalendarWidget(2026, 5, MagicMock()) + btn1 = cal.day_to_button[1] + self.app.manager.focused = btn1 + + # Container with the current calendar and a non-calendar widget + real_container = ptg.Container(cal, ptg.Label("test")) + + self.app.main_window.__iter__.side_effect = lambda: iter([real_container]) + + # Move to a date that won't be found (to trigger the full loop) + self.assertFalse(self.app._navigate_calendar("w")) # May 1 -> April 24 (not in May cal) + + def test_navigate_calendar_cross_widget(self): + """Test navigation between two different CalendarWidgets.""" + self.app.active_page = "calendar:0" + + cal1 = CalendarWidget(2026, 5, MagicMock()) # May + cal2 = CalendarWidget(2026, 6, MagicMock()) # June + + btn_may31 = cal1.day_to_button[31] + btn_june1 = cal2.day_to_button[1] + + self.app.manager.focused = btn_may31 + + real_container1 = ptg.Container(cal1) + real_container2 = ptg.Container(cal2) + + self.app.main_window.__iter__.side_effect = lambda: iter([real_container1, real_container2]) + self.app.main_window.selectables = [(btn_may31, 0), (btn_june1, 1)] + + # Move right ('d') from May 31 to June 1 + self.assertTrue(self.app._navigate_calendar("d")) + 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") - self.app.update_to_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() @@ -86,7 +411,7 @@ def test_run(self, _mock_window): # Mock run to exit immediately self.app.manager.run.side_effect = None - with patch.object(self.app, 'update_to_yesterday') as mock_update: + with patch.object(self.app, 'update_to_calendar') as mock_update: self.app.run() self.app.manager.add.assert_called_once() diff --git a/tests/widgets/test_game_widgets.py b/tests/widgets/test_game_widgets.py index 82a9528..fa13e3d 100644 --- a/tests/widgets/test_game_widgets.py +++ b/tests/widgets/test_game_widgets.py @@ -4,17 +4,20 @@ import unittest from unittest.mock import patch, MagicMock import pytermgui as ptg -from app.widgets.game_widgets import ( +from app.widgets import ( Separator, GameWidget, StandingWidget, NavigationWidget, + CalendarButton, + CalendarWidget, chunk_list, create_grid ) class TestGameWidgets(unittest.TestCase): """Test cases for game_widgets.py classes and functions.""" + # pylint: disable=protected-access def test_separator_init(self): """Test Separator initialization.""" @@ -38,7 +41,7 @@ def _get_label_values(self, widget): values.extend(self._get_label_values(child)) return values - @patch('app.widgets.game_widgets.get_team_abbr') + @patch('app.widgets.game_widget.get_team_abbr') def test_game_widget_init(self, mock_abbr): """Test GameWidget data mapping with inning data.""" mock_abbr.side_effect = lambda x: f"T{x}" @@ -62,7 +65,7 @@ def test_game_widget_init(self, mock_abbr): # Check inning label for Final game self.assertTrue(any("FINAL" in v for v in values)) - @patch('app.widgets.game_widgets.get_team_abbr') + @patch('app.widgets.game_widget.get_team_abbr') def test_game_widget_in_progress(self, mock_abbr): """Test GameWidget with in-progress inning data.""" mock_abbr.return_value = "TEST" @@ -86,7 +89,7 @@ def test_game_widget_in_progress(self, mock_abbr): values = self._get_label_values(widget) self.assertTrue(any("BOT 7" in v for v in values)) - @patch('app.widgets.game_widgets.get_team_abbr') + @patch('app.widgets.game_widget.get_team_abbr') def test_game_widget_scheduled(self, mock_abbr): """Test GameWidget with scheduled status shows start time.""" mock_abbr.return_value = "TEST" @@ -105,7 +108,7 @@ def test_game_widget_scheduled(self, mock_abbr): # Check for time (should contain 'AM' or 'PM' and ':') self.assertTrue(any(":" in v and ("AM" in v or "PM" in v) for v in values)) - @patch('app.widgets.game_widgets.get_team_abbr') + @patch('app.widgets.game_widget.get_team_abbr') def test_game_widget_none_scores(self, mock_abbr): """Test GameWidget with None scores shows '-'.""" mock_abbr.return_value = "TEST" @@ -121,7 +124,7 @@ def test_game_widget_none_scores(self, mock_abbr): values = self._get_label_values(widget) self.assertTrue(any("-" in v for v in values)) - @patch('app.widgets.game_widgets.get_team_abbr') + @patch('app.widgets.game_widget.get_team_abbr') def test_game_widget_malformed_time(self, mock_abbr): """Test GameWidget with malformed game_datetime.""" mock_abbr.return_value = "TEST" @@ -137,7 +140,7 @@ def test_game_widget_malformed_time(self, mock_abbr): # inning_text should be empty string self.assertTrue(all(v != "invalid-time" for v in values)) - @patch('app.widgets.game_widgets.get_team_abbr') + @patch('app.widgets.game_widget.get_team_abbr') def test_game_widget_in_progress_no_inning(self, mock_abbr): """Test GameWidget in-progress but with no inning data.""" mock_abbr.return_value = "TEST" @@ -154,7 +157,7 @@ def test_game_widget_in_progress_no_inning(self, mock_abbr): # Should not crash, and status should be empty self.assertTrue(any("TEST" in v for v in values)) - @patch('app.widgets.game_widgets.get_team_abbr') + @patch('app.widgets.standing_widget.get_team_abbr') def test_standing_widget_init(self, mock_abbr): """Test StandingWidget data mapping with pct and l10.""" mock_abbr.side_effect = lambda x: f"T{x}" @@ -195,12 +198,54 @@ def test_standing_widget_no_data(self): self.assertIn("No Data", labels[0]) def test_navigation_widget_init(self): - """Test NavigationWidget highlighting.""" - # Test yesterday active - widget = NavigationWidget(active_page="yesterday") - self.assertIn("[inverse]Yesterday", widget.yest_label.value) - self.assertNotIn("[inverse]Today", widget.today_label.value) - self.assertNotIn("[inverse]Standings", widget.stand_label.value) + """Test NavigationWidget content.""" + # Test schedule page + widget = NavigationWidget(active_page="schedule") + self.assertIn("Prev", widget.prev_label.value) + self.assertIn("Next", widget.next_label.value) + self.assertIn("Today", widget.today_label.value) + self.assertIn("Standings", widget.stand_label.value) + + # Test standings page + widget = NavigationWidget(active_page="standings") + self.assertIn("Schedule", widget.schedule_label.value) + + def test_calendar_button_mouse_handling(self): + """Test that CalendarButton ignores mouse events.""" + btn = CalendarButton("1", lambda _: None) + self.assertFalse(btn.handle_mouse(None)) + + def test_calendar_widget_init(self): + """Test CalendarWidget initialization and callback.""" + mock_on_selected = MagicMock() + # Instead of mocking Button, let's just let it be created but check if they exist + widget = CalendarWidget(2026, 5, mock_on_selected) + + # Verify first day (May 1, 2026 is Friday) + self.assertEqual(widget.year, 2026) + self.assertEqual(widget.month, 5) + # Check if we have splitters for weeks + splitters = [w for w in widget._widgets if isinstance(w, ptg.Splitter)] + # May 2026 has 6 weeks in Sun-Sat layout + self.assertEqual(len(splitters), 7) # 1 header + 6 weeks + + def test_calendar_widget_selected_day(self): + """Test CalendarWidget with a selected day highlighted.""" + mock_on_selected = MagicMock() + widget = CalendarWidget(2026, 5, mock_on_selected, selected_day=15) + # Verify the button for day 15 has 'inverse' style + btn = widget.day_to_button[15] + self.assertIn("inverse", str(btn.styles.label)) + # Verify another button has 'white' style + other_btn = widget.day_to_button[1] + self.assertIn("white", str(other_btn.styles.label)) + + def test_navigation_widget_calendar_init(self): + """Test NavigationWidget calendar mode.""" + widget = NavigationWidget(active_page="calendar") + self.assertIn("Prev Page", widget.prev_label.value) + self.assertIn("Next Page", widget.next_label.value) + self.assertIn("Standings", widget.stand_label.value) def test_chunk_list(self): """Test chunk_list utility.""" @@ -208,7 +253,7 @@ def test_chunk_list(self): chunks = list(chunk_list(lst, 2)) self.assertEqual(chunks, [[1, 2], [3, 4], [5]]) - @patch('app.widgets.game_widgets.GameWidget') + @patch('app.widgets.game_widget.GameWidget') def test_create_grid(self, mock_game_widget): """Test grid creation logic.""" mock_game_widget.return_value = MagicMock() From e021e88b756c78499b0567d5ce872322672a38f0 Mon Sep 17 00:00:00 2001 From: Conor Cleary Date: Sat, 16 May 2026 19:37:14 -0500 Subject: [PATCH 04/11] pylint fix --- tests/screens/test_main_screens.py | 4 +++- tests/test_mlb_cli.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/screens/test_main_screens.py b/tests/screens/test_main_screens.py index 0ff3d97..66aa9af 100644 --- a/tests/screens/test_main_screens.py +++ b/tests/screens/test_main_screens.py @@ -61,7 +61,9 @@ def test_get_calendar_widgets_selected_date(self): """Test calendar screen generation with a selected date highlighted.""" mock_on_selected = MagicMock() selected_date = datetime(2026, 4, 15) - widgets, _ = CalendarScreen.get_widgets(2026, [3, 4, 5], mock_on_selected, selected_date=selected_date) + widgets, _ = CalendarScreen.get_widgets( + 2026, [3, 4, 5], mock_on_selected, selected_date=selected_date + ) # Check if the correct month container received the selected day # widgets[1] is March, widgets[2] is April, widgets[3] is May diff --git a/tests/test_mlb_cli.py b/tests/test_mlb_cli.py index d804f5d..1933614 100644 --- a/tests/test_mlb_cli.py +++ b/tests/test_mlb_cli.py @@ -264,7 +264,7 @@ def test_navigate_calendar_state_checks(self): def test_navigate_calendar_success(self): """Test successful WASD navigation in calendar.""" self.app.active_page = "calendar:0" - + # Create CalendarWidget with day 1 and day 2 mock_on_selected = MagicMock() cal = CalendarWidget(2026, 5, mock_on_selected) @@ -276,7 +276,7 @@ def test_navigate_calendar_success(self): # Real Container with CalendarWidget real_container = ptg.Container(cal) - + self.app.main_window.__iter__.side_effect = lambda: iter([real_container]) # selectables: (button, index) self.app.main_window.selectables = [(btn1, 0), (btn2, 1)] @@ -292,7 +292,7 @@ def test_navigate_calendar_invalid_direction(self): cal = CalendarWidget(2026, 5, MagicMock()) btn1 = cal.day_to_button[1] self.app.manager.focused = btn1 - + real_container = ptg.Container(cal) self.app.main_window.__iter__.side_effect = lambda: iter([real_container]) @@ -304,7 +304,7 @@ def test_navigate_calendar_target_not_found(self): cal = CalendarWidget(2026, 5, MagicMock()) btn31 = cal.day_to_button[31] # Last day of May self.app.manager.focused = btn31 - + real_container = ptg.Container(cal) self.app.main_window.__iter__.side_effect = lambda: iter([real_container]) @@ -314,11 +314,11 @@ 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" - + cal = CalendarWidget(2026, 5, MagicMock()) btn1 = cal.day_to_button[1] self.app.manager.focused = btn1 - + real_container = ptg.Container(cal) # Yield a Label THEN a Container to hit first loop's continue # Then yield a Label THEN a Container to hit second loop's continue @@ -326,10 +326,10 @@ def test_navigate_calendar_not_a_container(self): iter([ptg.Label("test"), real_container]), # First loop iter([ptg.Label("test"), real_container]) # Second loop ] - + btn2 = cal.day_to_button[2] self.app.main_window.selectables = [(btn1, 0), (btn2, 1)] - + self.assertTrue(self.app._navigate_calendar("d")) def test_navigate_calendar_focused_not_in_calendar(self): From fe69790370ebee627c18778169d1880044a319a5 Mon Sep 17 00:00:00 2001 From: Conor Cleary Date: Sat, 16 May 2026 19:51:20 -0500 Subject: [PATCH 05/11] Fix the calendar pagination navigation bug -- more work to come --- AGENTS.md | 2 +- mlb_cli.py | 23 ++++++++++------------- tests/test_mlb_cli.py | 35 +++++++++++++++++++++++++---------- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d6ceee8..2a94907 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,6 @@ To ensure clean code structure and organization: - Larger functionality requests should be broken down in reusable functions for single pieces of functionality. ## Testing Instructions -- All changes MUST have their style verified. Use pylint as described in @.github/workflows/pylint.yml after making any code changes. +- All changes MUST have their style verified. Use pylint as described in @.github/workflows/pylint.yml after making any code changes. Ensure tests are included in linting checks. - All changes MUST be verified by running pytest as described in the README. All tests must pass and code coverage in @app/ must remain at 100%. - Add unit tests for any new functionality to ensure 100% coverage. diff --git a/mlb_cli.py b/mlb_cli.py index 6da2060..365608f 100644 --- a/mlb_cli.py +++ b/mlb_cli.py @@ -109,13 +109,14 @@ def toggle_standings(self, *_args, **_kwargs): self.set_window_data(widgets, title, "standings") return True - def update_to_calendar(self, *_args, **_kwargs): + def update_to_calendar(self, *_args, sync_page=True, **_kwargs): """Transitions to the calendar view.""" if self.active_page == "standings": self.current_date = datetime.now() - # Ensure calendar_page matches current_date - self._determine_initial_calendar_page() + if sync_page: + # Ensure calendar_page matches current_date + self._determine_initial_calendar_page() pages = [ [3, 4, 5], @@ -224,18 +225,14 @@ def _navigate_calendar(self, direction): return False def go_to_previous_page(self, *_args, **_kwargs): - """Moves calendar view to the previous page.""" - if self.calendar_page > 0: - self.calendar_page -= 1 - return self.update_to_calendar() - return True + """Moves calendar view to the previous page with wrapping.""" + self.calendar_page = (self.calendar_page - 1) % 3 + return self.update_to_calendar(sync_page=False) def go_to_next_page(self, *_args, **_kwargs): - """Moves calendar view to the next page.""" - if self.calendar_page < 2: - self.calendar_page += 1 - return self.update_to_calendar() - return True + """Moves calendar view to the next page with wrapping.""" + self.calendar_page = (self.calendar_page + 1) % 3 + return self.update_to_calendar(sync_page=False) def go_to_today(self, *_args, **_kwargs): """Resets the current date to today and updates the view.""" diff --git a/tests/test_mlb_cli.py b/tests/test_mlb_cli.py index 1933614..a116d26 100644 --- a/tests/test_mlb_cli.py +++ b/tests/test_mlb_cli.py @@ -140,16 +140,16 @@ def test_go_to_next_day_calendar(self): mock_go.assert_called_once() def test_go_to_previous_page_boundary(self): - """Test boundary for go_to_previous_page.""" + """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, 0) + self.assertEqual(self.app.calendar_page, 2) def test_go_to_next_page_boundary(self): - """Test boundary for go_to_next_page.""" + """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, 2) + self.assertEqual(self.app.calendar_page, 0) def test_focus_current_date_in_calendar_day_missing(self): """Test _focus_current_date_in_calendar when day is missing from button map.""" @@ -229,18 +229,33 @@ def test_update_to_calendar_from_standings(self, mock_get): ) def test_go_to_previous_page(self): - """Test decrementing calendar page.""" - self.app.calendar_page = 1 + """Test decrementing calendar page with wrapping.""" + self.app.calendar_page = 0 with patch.object(self.app, 'update_to_calendar'): self.app.go_to_previous_page() - self.assertEqual(self.app.calendar_page, 0) + self.assertEqual(self.app.calendar_page, 2) def test_go_to_next_page(self): - """Test incrementing calendar page.""" - self.app.calendar_page = 1 + """Test incrementing calendar page with wrapping.""" + self.app.calendar_page = 2 with patch.object(self.app, 'update_to_calendar'): self.app.go_to_next_page() - self.assertEqual(self.app.calendar_page, 2) + self.assertEqual(self.app.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 + + # 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: + mock_get.return_value = ([], "Title") + + # Go to next page + self.app.go_to_next_page() + + self.assertEqual(self.app.calendar_page, 1) def test_on_calendar_date_selected(self): """Test selecting a date from calendar.""" From 7426016f1bccf40aee659407182f668d7ceccbbc Mon Sep 17 00:00:00 2001 From: Conor Cleary Date: Sat, 16 May 2026 20:05:07 -0500 Subject: [PATCH 06/11] Fix bug #2: ensure the intended focus date is in focus when returning to calendar from schedule or standings --- app/widgets/animations.py | 12 ++++++++++-- mlb_cli.py | 18 ++++++++++++------ tests/test_mlb_cli.py | 15 +++++++++++++-- tests/widgets/test_animations.py | 24 ++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/app/widgets/animations.py b/app/widgets/animations.py index d9905ad..794913d 100644 --- a/app/widgets/animations.py +++ b/app/widgets/animations.py @@ -6,7 +6,8 @@ from pytermgui.animations import Direction -def slide_transition(window, manager, widgets, title, duration=290): +# pylint: disable=too-many-arguments,too-many-positional-arguments +def slide_transition(window, manager, widgets, title, duration=290, on_finish=None): """ Performs a slide-out and slide-in transition for a window. @@ -16,6 +17,7 @@ def slide_transition(window, manager, widgets, title, duration=290): widgets (list): The new widgets to set after sliding out. title (str): The new title to set after sliding out. duration (int): Duration of each slide stage in milliseconds. + on_finish (callable, optional): Callback to run when the entire transition finishes. """ static_width = window.width static_height = window.height @@ -30,6 +32,11 @@ def slide_step(anim, off_x): window.pos = (curr_x, start_y) return False + def on_slide_in_finish(_): + """Runs the external on_finish callback if provided.""" + if on_finish: + on_finish() + def on_slide_out_finish(_): """Updates window content and starts the slide-in animation using Direction.BACKWARD.""" window.set_widgets(widgets) @@ -42,7 +49,8 @@ def on_slide_out_finish(_): ptg.animator.animate_float( duration=duration, direction=Direction.BACKWARD, - on_step=lambda anim: slide_step(anim, off_screen_right) + on_step=lambda anim: slide_step(anim, off_screen_right), + on_finish=on_slide_in_finish ) # Start the slide-out animation using Direction.FORWARD diff --git a/mlb_cli.py b/mlb_cli.py index 365608f..d10beba 100644 --- a/mlb_cli.py +++ b/mlb_cli.py @@ -47,9 +47,11 @@ def _determine_initial_calendar_page(self): else: self.calendar_page = 2 - def set_window_data(self, widgets, title, page_name): + 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 on_finish: + on_finish() return self.active_page = page_name @@ -62,9 +64,11 @@ def set_window_data(self, widgets, title, page_name): self.main_window.styles.border = "green" self.main_window.styles.corner = "green" self.main_window.center() + if on_finish: + on_finish() return - slide_transition(self.main_window, self.manager, widgets, title) + slide_transition(self.main_window, self.manager, widgets, title, on_finish=on_finish) def update_to_schedule(self, *_args, **_kwargs): """Transitions the main window to show the schedule for current_date.""" @@ -130,10 +134,12 @@ def update_to_calendar(self, *_args, sync_page=True, **_kwargs): self.on_calendar_date_selected, selected_date=self.current_date ) - self.set_window_data(widgets, title, f"calendar:{self.calendar_page}") - - # Focus current date if it's on this page - self._focus_current_date_in_calendar() + self.set_window_data( + widgets, + title, + f"calendar:{self.calendar_page}", + on_finish=self._focus_current_date_in_calendar + ) return True def _focus_current_date_in_calendar(self): diff --git a/tests/test_mlb_cli.py b/tests/test_mlb_cli.py index a116d26..24bb8cb 100644 --- a/tests/test_mlb_cli.py +++ b/tests/test_mlb_cli.py @@ -44,6 +44,13 @@ def test_set_window_data_same_page(self): 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) @@ -210,8 +217,12 @@ def test_go_to_today(self): def test_update_to_calendar(self, mock_get): """Test transition to calendar.""" mock_get.return_value = ([], "Calendar") - self.app.update_to_calendar() - self.assertEqual(self.app.active_page, f"calendar:{self.app.calendar_page}") + 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 + self.assertEqual(kwargs['on_finish'], self.app._focus_current_date_in_calendar) @patch('mlb_cli.CalendarScreen.get_widgets') def test_update_to_calendar_from_standings(self, mock_get): diff --git a/tests/widgets/test_animations.py b/tests/widgets/test_animations.py index 31e5083..136be45 100644 --- a/tests/widgets/test_animations.py +++ b/tests/widgets/test_animations.py @@ -11,6 +11,7 @@ class TestAnimations(unittest.TestCase): @patch('pytermgui.animator.animate_float') def test_slide_transition(self, mock_animate): + # pylint: disable=too-many-locals """Test slide_transition starts the animation and handles callbacks.""" mock_window = MagicMock(spec=ptg.Window) mock_window.width = 80 @@ -60,5 +61,28 @@ def test_slide_transition(self, mock_animate): on_step_in(mock_anim_in) self.assertEqual(mock_window.pos, (28, 5)) + # 3. Test on_finish for slide-in + mock_finish = MagicMock() + + # We need to re-run slide_transition with on_finish to test it properly + mock_animate.reset_mock() + slide_transition(mock_window, mock_manager, widgets, title, on_finish=mock_finish) + + # Get the new slide-out finish + _, kwargs_out = mock_animate.call_args + on_finish_out = kwargs_out['on_finish'] + mock_animate.reset_mock() + + # Trigger slide-out finish -> starts slide-in + on_finish_out(None) + + # Get the new slide-in finish + _, kwargs_in_final = mock_animate.call_args + on_finish_final = kwargs_in_final['on_finish'] + + # Trigger slide-in finish + on_finish_final(None) + mock_finish.assert_called_once() + if __name__ == '__main__': # pragma: no cover unittest.main() From eba4dcce1c68ffedfc869218f981c7a31bdf60bb Mon Sep 17 00:00:00 2001 From: Conor Cleary Date: Sat, 16 May 2026 20:11:53 -0500 Subject: [PATCH 07/11] Fix last bug: ensure a date is selected when paginating in the calendar -- stil need to perform testing --- mlb_cli.py | 61 ++++++++++++++++++++++++++++++------------- tests/test_mlb_cli.py | 25 +++++++++++++++++- 2 files changed, 67 insertions(+), 19 deletions(-) diff --git a/mlb_cli.py b/mlb_cli.py index d10beba..f8a0068 100644 --- a/mlb_cli.py +++ b/mlb_cli.py @@ -113,7 +113,7 @@ def toggle_standings(self, *_args, **_kwargs): self.set_window_data(widgets, title, "standings") return True - def update_to_calendar(self, *_args, sync_page=True, **_kwargs): + 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() @@ -138,28 +138,53 @@ def update_to_calendar(self, *_args, sync_page=True, **_kwargs): widgets, title, f"calendar:{self.calendar_page}", - on_finish=self._focus_current_date_in_calendar + on_finish=lambda: self._focus_current_date_in_calendar(target=focus_target) ) return True - def _focus_current_date_in_calendar(self): - """Helper to find and focus current_date in the calendar view.""" + def _focus_current_date_in_calendar(self, target=None): + # pylint: disable=too-many-branches + """ + Helper to find and focus a date in the calendar view. + If target is None, focuses current_date. + If target is 'first', focuses first date of first month. + If target is 'last', focuses last date of last month. + """ + calendar_widgets = [] for widget in self.main_window: if not isinstance(widget, ptg.Container): continue for sub in widget: - if not (isinstance(sub, CalendarWidget) and - sub.month == self.current_date.month): - continue - day = self.current_date.day - if day not in sub.day_to_button: - continue - btn = sub.day_to_button[day] - for i, (selectable, _) in enumerate(self.main_window.selectables): - if selectable is btn: - self.main_window.select(i) - self.manager.focused = btn - return True + if isinstance(sub, CalendarWidget): + calendar_widgets.append(sub) + + if not calendar_widgets: + return False + + target_btn = None + if target == "first": + sub = calendar_widgets[0] + first_day = min(sub.day_to_button.keys()) + target_btn = sub.day_to_button[first_day] + elif target == "last": + sub = calendar_widgets[-1] + last_day = max(sub.day_to_button.keys()) + target_btn = sub.day_to_button[last_day] + else: + # Default: focus current_date + for sub in calendar_widgets: + if sub.month == self.current_date.month: + day = self.current_date.day + if day in sub.day_to_button: + target_btn = sub.day_to_button[day] + break + + if target_btn: + for i, (selectable, _) in enumerate(self.main_window.selectables): + if selectable is target_btn: + self.main_window.select(i) + self.manager.focused = target_btn + return True return False def on_calendar_date_selected(self, year, month, day): @@ -233,12 +258,12 @@ 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 - return self.update_to_calendar(sync_page=False) + 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 - return self.update_to_calendar(sync_page=False) + 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.""" diff --git a/tests/test_mlb_cli.py b/tests/test_mlb_cli.py index 24bb8cb..126fd22 100644 --- a/tests/test_mlb_cli.py +++ b/tests/test_mlb_cli.py @@ -222,7 +222,30 @@ def test_update_to_calendar(self, mock_get): self.assertEqual(self.app.active_page, f"calendar:{self.app.calendar_page}") mock_set.assert_called_once() _, kwargs = mock_set.call_args - self.assertEqual(kwargs['on_finish'], self.app._focus_current_date_in_calendar) + # 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() + cal1 = CalendarWidget(2026, 5, mock_on_selected) # May + cal2 = CalendarWidget(2026, 6, mock_on_selected) # June + + btn_may1 = cal1.day_to_button[1] + btn_june30 = cal2.day_to_button[30] + + real_container1 = ptg.Container(cal1) + real_container2 = ptg.Container(cal2) + self.app.main_window.__iter__.side_effect = lambda: iter([real_container1, real_container2]) + self.app.main_window.selectables = [(btn_may1, 0), (btn_june30, 1)] + + # 1. Test 'first' + self.assertTrue(self.app._focus_current_date_in_calendar(target="first")) + self.assertEqual(self.app.manager.focused, btn_may1) + + # 2. Test 'last' + 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_from_standings(self, mock_get): From eed7392649e7eacb99297d43b223e57813497a27 Mon Sep 17 00:00:00 2001 From: Conor Cleary Date: Sat, 16 May 2026 20:17:47 -0500 Subject: [PATCH 08/11] doc updates --- AGENTS.md | 1 + README.md | 99 ++++++++++++++++++++++++++++++------------------------- 2 files changed, 56 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2a94907..003cacf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,7 @@ To ensure clean code structure and organization: - All new widget types should be generated in their own file and class within the @app/widgets/ directory. - All new screens should be generated in their own file and cliss within the @app/screens/ directory. - Larger functionality requests should be broken down in reusable functions for single pieces of functionality. +- When updating or adding functionality, ensure relevant usage information is added to the project README. ## Testing Instructions - All changes MUST have their style verified. Use pylint as described in @.github/workflows/pylint.yml after making any code changes. Ensure tests are included in linting checks. diff --git a/README.md b/README.md index 6d59c40..2dfecb8 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,15 @@ # MLB Box Score TUI -A Python-based Terminal User Interface (TUI) for Major League Baseball (MLB) scores, schedules, and standings. Built with `pytermgui` and the `MLB-StatsAPI`. +A high-performance, Python-based Terminal User Interface (TUI) for Major League Baseball (MLB) scores, schedules, and standings. Built with `pytermgui` and the `MLB-StatsAPI`, featuring a modular architecture and 100% test coverage. ## Features -- **Yesterday's Schedule:** See all games from the previous day, including matchups and final scores. -- **Today's Schedule:** See upcoming games for the current day, including matchups and status. -- **League Standings:** Display current standings for all 6 MLB divisions (AL and NL). - -## Screenshots - -*(Insert screenshots of your TUI here)* +- **Full Season Schedule:** Navigate the entire 2026 MLB season through a dedicated calendar view or daily snapshots. +- **Dynamic Standings:** Real-time standings for all 6 MLB divisions (AL and NL), including comprehensive **Wild Card** rankings. +- **Interactive Calendar:** A multi-month calendar view for quick date selection, featuring intuitive **WASD** keyboard navigation and automatic focus management. +- **Smart Caching:** Built-in caching service to minimize API calls and ensure a responsive user experience. +- **Smooth Transitions:** Animated screen transitions for a modern, fluid feel. +- **Robust Architecture:** Surgical, class-based organization of widgets and screens for high maintainability. ## Installation @@ -46,67 +45,79 @@ Run the application using the main entry point: python3 mlb_cli.py ``` -## Testing +## Controls -The project includes a comprehensive unit test suite covering data services, screen layouts, and custom widgets. +The application is designed for rapid keyboard-driven navigation. -### Running Tests - -To run the full test suite using `pytest`: - -```bash -pytest -``` +### Global Controls -To run tests with coverage reporting: - -```bash -pytest --cov=app tests/ -``` +| Key | Action | +| --- | --- | +| `[` | Previous Day / Previous Calendar Page (with wrapping) | +| `]` | Next Day / Next Calendar Page (with wrapping) | +| `t` | Jump to Today's Schedule | +| `c` | Switch to Calendar View | +| `x` | Toggle Standings View | +| `ESC` | Exit the application | +| `Tab` | Cycle focus between UI components | -The tests utilize `unittest.mock` to ensure isolation from external APIs and UI side effects. +### Calendar Navigation -### Controls +When in the Calendar view, you can use specialized keys for precise date selection: | Key | Action | | --- | --- | -| `[` | Switch to Yesterday's Scores | -| `]` | Switch to Today's Schedule | -| `s` | Switch to League Standings | -| `ESC` | Exit the application | -| `Arrow Keys` / `Tab` | Navigate between focusable items (games, divisions, buttons) | -| `Enter` | Trigger navigation buttons | +| `W` | Move focus up one week | +| `A` | Move focus left one day | +| `S` | Move focus down one week | +| `D` | Move focus right one day | +| `Enter` | Select the focused date and view its schedule | ## Project Structure +The project follows a strict modular design: + ```text mlb-cli-py/ -├── mlb_cli.py # Main entry point +├── mlb_cli.py # Main application controller and state management ├── app/ -│ ├── models/ # Data fetching and processing (MLB-StatsAPI) -│ ├── widgets/ # Custom TUI components (GameWidget, StandingWidget) -│ └── screens/ # High-level window and layout definitions +│ ├── models/ # Data services (API fetching, caching, date utilities) +│ ├── widgets/ # Modular TUI components (Game, Standing, Calendar widgets) +│ └── screens/ # Class-based screen definitions (Schedule, Standings, Calendar) +├── tests/ # 100% covered test suite (Unit and integration tests) ├── requirements.txt # Project dependencies └── README.md # You are here! ``` -## Contributing +## Development & Testing -Future planned features include: +The project maintains a perfect **10.00/10 Pylint score** and **100% test coverage**. -- Cycling through games to view full box scores. -- Live score updates for ongoing games. -- Team-specific filtering. +### Running Tests -Feel free to open issues or submit pull requests! +To run the full test suite using `pytest`: -## Acknowledgements +```bash +pytest +``` -This project would not be possible without the [MLB-StatsAPI](https://github.com/toddrob99/MLB-StatsAPI) and [PyTermGUI](https://github.com/bczsalba/pytermgui) projects and if you are interested in this application you should explore those projects further! +To run tests with coverage reporting: -## AI Disclaimer +```bash +pytest --cov=app --cov=mlb_cli tests/ +``` + +### Linting + +To verify code quality: + +```bash +pylint mlb_cli.py app tests +``` + +## Acknowledgements -This project was generated by AI, specifically the gemini-3-pro-preview model. +This project would not be possible without the [MLB-StatsAPI](https://github.com/toddrob99/MLB-StatsAPI) and [PyTermGUI](https://github.com/bczsalba/pytermgui) projects. ## License From 81dd9323336c8e67b4b3124926a76d24b3b391a1 Mon Sep 17 00:00:00 2001 From: Conor Cleary Date: Sat, 16 May 2026 21:08:17 -0500 Subject: [PATCH 09/11] fix game status display bug for middle and end of inning --- app/widgets/game_widget.py | 4 ++++ tests/widgets/test_game_widgets.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/app/widgets/game_widget.py b/app/widgets/game_widget.py index 691d706..5ed8525 100644 --- a/app/widgets/game_widget.py +++ b/app/widgets/game_widget.py @@ -70,6 +70,10 @@ def _get_inning_text(self, game, status): prefix = "TOP" elif inning_state.lower().startswith('bottom'): prefix = "BOT" + elif inning_state.lower().startswith('middle'): + prefix = "MID" + elif inning_state.lower().startswith('end'): + prefix = "END" return f"{prefix} {inning_val}" return "" diff --git a/tests/widgets/test_game_widgets.py b/tests/widgets/test_game_widgets.py index fa13e3d..295c8dd 100644 --- a/tests/widgets/test_game_widgets.py +++ b/tests/widgets/test_game_widgets.py @@ -140,6 +140,29 @@ def test_game_widget_malformed_time(self, mock_abbr): # inning_text should be empty string self.assertTrue(all(v != "invalid-time" for v in values)) + @patch('app.widgets.game_widget.get_team_abbr') + def test_game_widget_inning_states(self, mock_abbr): + """Test GameWidget with MID and END inning states.""" + mock_abbr.return_value = "TEST" + # Middle of 5th + game = { + 'game_id': 123, + 'away_id': 1, + 'home_id': 2, + 'status': 'Live', + 'current_inning': 5, + 'inning_state': 'Middle' + } + widget = GameWidget(game) + values = self._get_label_values(widget) + self.assertTrue(any("MID 5" in v for v in values)) + + # End of 5th + game['inning_state'] = 'End' + widget = GameWidget(game) + values = self._get_label_values(widget) + self.assertTrue(any("END 5" in v for v in values)) + @patch('app.widgets.game_widget.get_team_abbr') def test_game_widget_in_progress_no_inning(self, mock_abbr): """Test GameWidget in-progress but with no inning data.""" From bcb966b88ceeaa189259c6b13cd768cc9d8cb98c Mon Sep 17 00:00:00 2001 From: Conor Cleary Date: Sat, 16 May 2026 21:55:16 -0500 Subject: [PATCH 10/11] dynamic window sizing implementation --- app/widgets/animations.py | 25 +++++++++++++++---------- mlb_cli.py | 25 ++++++++++++++++++++++--- pyproject.toml | 2 ++ tests/test_mlb_cli.py | 2 ++ 4 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 pyproject.toml diff --git a/app/widgets/animations.py b/app/widgets/animations.py index 794913d..a8d7986 100644 --- a/app/widgets/animations.py +++ b/app/widgets/animations.py @@ -6,8 +6,9 @@ from pytermgui.animations import Direction -# pylint: disable=too-many-arguments,too-many-positional-arguments -def slide_transition(window, manager, widgets, title, duration=290, on_finish=None): +# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals +def slide_transition(window, manager, widgets, title, duration=290, on_finish=None, + new_width=None, new_height=None): """ Performs a slide-out and slide-in transition for a window. @@ -18,17 +19,21 @@ def slide_transition(window, manager, widgets, title, duration=290, on_finish=No title (str): The new title to set after sliding out. duration (int): Duration of each slide stage in milliseconds. on_finish (callable, optional): Callback to run when the entire transition finishes. + new_width (int, optional): The target width for the window. + new_height (int, optional): The target height for the window. """ - static_width = window.width - static_height = window.height + # Use provided dimensions or fallback to current ones + target_width = new_width if new_width is not None else window.width + target_height = new_height if new_height is not None else window.height - target_x = (manager.terminal.width - static_width) // 2 + # Calculate starting position for slide-in (centered) + final_target_x = (manager.terminal.width - target_width) // 2 _, start_y = window.pos off_screen_right = manager.terminal.width - def slide_step(anim, off_x): + def slide_step(anim, off_x, current_target_x): """Updates the window position based on animation state and off-screen target.""" - curr_x = int(target_x + (off_x - target_x) * anim.state) + curr_x = int(current_target_x + (off_x - current_target_x) * anim.state) window.pos = (curr_x, start_y) return False @@ -41,15 +46,15 @@ def on_slide_out_finish(_): """Updates window content and starts the slide-in animation using Direction.BACKWARD.""" window.set_widgets(widgets) window.set_title(title) - window.width = static_width - window.height = static_height + window.width = target_width + window.height = target_height window.styles.border = "green" window.styles.corner = "green" ptg.animator.animate_float( duration=duration, direction=Direction.BACKWARD, - on_step=lambda anim: slide_step(anim, off_screen_right), + on_step=lambda anim: slide_step(anim, off_screen_right, final_target_x), on_finish=on_slide_in_finish ) diff --git a/mlb_cli.py b/mlb_cli.py index f8a0068..c221a16 100644 --- a/mlb_cli.py +++ b/mlb_cli.py @@ -15,11 +15,17 @@ ) from app.widgets import CalendarWidget, slide_transition +# 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 + class MLBApp: """ Main application class that manages the WindowManager and screen transitions. """ + # pylint: disable=too-many-instance-attributes def __init__(self): @@ -54,13 +60,19 @@ def set_window_data(self, widgets, title, page_name, on_finish=None): on_finish() return + # Calculate required height based on content + max_height = self.manager.terminal.height - 2 + temp_window = ptg.Window(*widgets, width=self.static_width) + temp_window.set_title(title) + target_height = min(max_height, temp_window.height) + self.active_page = page_name if not self.is_initialized: self.is_initialized = True self.main_window.set_widgets(widgets) self.main_window.set_title(title) self.main_window.width = self.static_width - self.main_window.height = self.static_height + self.main_window.height = target_height self.main_window.styles.border = "green" self.main_window.styles.corner = "green" self.main_window.center() @@ -68,7 +80,14 @@ def set_window_data(self, widgets, title, page_name, on_finish=None): on_finish() return - slide_transition(self.main_window, self.manager, widgets, title, on_finish=on_finish) + slide_transition( + self.main_window, + self.manager, + widgets, + title, + on_finish=on_finish, + new_height=target_height + ) def update_to_schedule(self, *_args, **_kwargs): """Transitions the main window to show the schedule for current_date.""" @@ -278,7 +297,7 @@ def exit_app(self, *_args, **_kwargs): def run(self): """Starts the application main loop.""" with self.manager: - self.static_height = min(50, self.manager.terminal.height - 2) + self.static_height = min(INITIAL_HEIGHT, self.manager.terminal.height - 2) self.main_window = ptg.Window( width=self.static_width, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d032614 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.pylint.messages_control] +disable = ["fixme"] \ No newline at end of file diff --git a/tests/test_mlb_cli.py b/tests/test_mlb_cli.py index 126fd22..cfd7b56 100644 --- a/tests/test_mlb_cli.py +++ b/tests/test_mlb_cli.py @@ -18,6 +18,8 @@ def setUp(self): 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([]) From 703d2dd7fd92d0c0d8040b10d932efed865538a2 Mon Sep 17 00:00:00 2001 From: Conor Cleary Date: Sat, 16 May 2026 22:10:06 -0500 Subject: [PATCH 11/11] AGENTS update --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 003cacf..7651ae5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ To ensure clean code structure and organization: - All new screens should be generated in their own file and cliss within the @app/screens/ directory. - Larger functionality requests should be broken down in reusable functions for single pieces of functionality. - When updating or adding functionality, ensure relevant usage information is added to the project README. +- This application uses a redis cache for data pulled from the MLB-StatsAPI library. If new functionality requires new data_service requests ensure these requests leverage the application caching strategy with reasonable TTLs. ## Testing Instructions - All changes MUST have their style verified. Use pylint as described in @.github/workflows/pylint.yml after making any code changes. Ensure tests are included in linting checks.