From b8d0e2a00fae22f253244389be85d59f4f6551ef Mon Sep 17 00:00:00 2001 From: Conor Cleary Date: Mon, 25 May 2026 12:54:35 -0500 Subject: [PATCH] Bug fixes for v0.2.0: * Fix hotkey 't' behavior in calendar view -> bring current date into focus but dont navigate to scores grid * Ignore pagination keys in the standings view since pagination does not make sense in that context * Stop mouse clicks from crashing the application --- app/mlb_cli.py | 30 ++++++++++++++++--- tests/test_calendar_interaction.py | 34 +++++++++++++--------- tests/test_navigation_logic.py | 39 +++++++++++++++++++++++-- tests/test_selection_persistence.py | 45 +++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 20 deletions(-) create mode 100644 tests/test_selection_persistence.py diff --git a/app/mlb_cli.py b/app/mlb_cli.py index 8d8c0f5..1a97300 100644 --- a/app/mlb_cli.py +++ b/app/mlb_cli.py @@ -4,6 +4,7 @@ """ from datetime import datetime, timedelta import pytermgui as ptg +from pytermgui.ansi_interface import MouseEvent, MouseAction from .models.data_service import ( fetch_teams, format_date @@ -23,6 +24,21 @@ logger = get_logger(__name__) +class MLBWindow(ptg.Window): + """ + A custom Window class that prevents deselection on blur. + """ + + def blur(self) -> None: + """Blurs the window without deselecting its content.""" + self.has_focus = False + self.handle_mouse(MouseEvent(MouseAction.RELEASE, (0, 0))) + + if not self.is_noblur: + self.styles.border = self.styles.border_blurred + self.styles.corner = self.styles.corner_blurred + + class MLBApp: """ TUI Manager class for the MLB CLI application. @@ -104,6 +120,9 @@ def go_to_previous_day(self, *_args, **_kwargs): if self.state.on_calendar_screen: return self.go_to_previous_page() + if self.state.on_standings_screen: + return True + if self.state.decrement_date(): return self.update_to_schedule() return True @@ -113,6 +132,9 @@ def go_to_next_day(self, *_args, **_kwargs): if self.state.on_calendar_screen: return self.go_to_next_page() + if self.state.on_standings_screen: + return True + if self.state.increment_date(): return self.update_to_schedule() return True @@ -198,7 +220,6 @@ def _focus_current_date_in_calendar(self, target=None): 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 @@ -216,7 +237,7 @@ def _navigate_calendar(self, direction): if not self.state.on_calendar_screen: return False - focused = self.manager.focused + focused = self.main_window.selected if focused is None: return False @@ -266,7 +287,6 @@ def _navigate_calendar(self, direction): 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 @@ -283,6 +303,8 @@ def go_to_next_page(self, *_args, **_kwargs): def go_to_today(self, *_args, **_kwargs): """Resets the current date to today and updates the view.""" self.state.reset_to_today() + if self.state.on_calendar_screen: + return self.update_to_calendar() return self.update_to_schedule() def exit_app(self, *_args, **_kwargs): @@ -295,7 +317,7 @@ def run(self): with self.manager: self.static_height = min(INITIAL_HEIGHT, self.manager.terminal.height - 2) - self.main_window = ptg.Window( + self.main_window = MLBWindow( width=self.static_width, height=self.static_height, is_static=True, diff --git a/tests/test_calendar_interaction.py b/tests/test_calendar_interaction.py index a1778d4..e8f378b 100644 --- a/tests/test_calendar_interaction.py +++ b/tests/test_calendar_interaction.py @@ -24,6 +24,14 @@ def setUp(self): self.app.main_window = MagicMock(spec=ptg.Window) self.app.main_window.__iter__.return_value = iter([]) + def mock_select(index): + """Mock select to update selected attribute.""" + main_window = self.app.main_window + if hasattr(main_window, 'selectables') and index < len(main_window.selectables): + main_window.selected = main_window.selectables[index][0] + + self.app.main_window.select.side_effect = mock_select + def test_focus_current_date_in_calendar(self): """Test _focus_current_date_in_calendar with various widget structures.""" # Hit: deep structure @@ -40,7 +48,7 @@ def test_focus_current_date_in_calendar(self): 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) + self.assertEqual(self.app.main_window.selected, mock_btn) def test_focus_current_date_in_calendar_not_a_container(self): """Test _focus_current_date_in_calendar skips non-container widgets.""" @@ -86,11 +94,11 @@ def test_focus_current_date_in_calendar_targets(self): # 1. Test 'first' self.assertTrue(self.app._focus_current_date_in_calendar(target="first")) - self.assertEqual(self.app.manager.focused, btn_may1) + self.assertEqual(self.app.main_window.selected, btn_may1) # 2. Test 'last' self.assertTrue(self.app._focus_current_date_in_calendar(target="last")) - self.assertEqual(self.app.manager.focused, btn_june30) + self.assertEqual(self.app.main_window.selected, btn_june30) @patch('app.mlb_cli.CalendarScreen.get_widgets') def test_update_to_calendar(self, mock_get): @@ -163,7 +171,7 @@ def test_navigate_calendar_state_checks(self): # 2. On calendar page, but nothing focused self.app.state.active_page = "calendar:0" - self.app.manager.focused = None + self.app.main_window.selected = None self.assertFalse(self.app._navigate_calendar("w")) def test_navigate_calendar_success(self): @@ -177,7 +185,7 @@ def test_navigate_calendar_success(self): btn2 = cal.day_to_button[2] # Put button 1 in focus - self.app.manager.focused = btn1 + self.app.main_window.selected = btn1 # Real Container with CalendarWidget real_container = ptg.Container(cal) @@ -189,14 +197,14 @@ def test_navigate_calendar_success(self): # 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) + self.assertEqual(self.app.main_window.selected, btn2) def test_navigate_calendar_invalid_direction(self): """Test _navigate_calendar with invalid direction.""" self.app.state.active_page = "calendar:0" cal = CalendarWidget(2026, 5, MagicMock()) btn1 = cal.day_to_button[1] - self.app.manager.focused = btn1 + self.app.main_window.selected = btn1 real_container = ptg.Container(cal) self.app.main_window.__iter__.side_effect = lambda: iter([real_container]) @@ -208,7 +216,7 @@ def test_navigate_calendar_target_not_found(self): self.app.state.active_page = "calendar:0" cal = CalendarWidget(2026, 5, MagicMock()) btn31 = cal.day_to_button[31] # Last day of May - self.app.manager.focused = btn31 + self.app.main_window.selected = btn31 real_container = ptg.Container(cal) self.app.main_window.__iter__.side_effect = lambda: iter([real_container]) @@ -222,7 +230,7 @@ def test_navigate_calendar_not_a_container(self): cal = CalendarWidget(2026, 5, MagicMock()) btn1 = cal.day_to_button[1] - self.app.manager.focused = btn1 + self.app.main_window.selected = btn1 real_container = ptg.Container(cal) # Yield a Label THEN a Container to hit first loop's continue @@ -241,7 +249,7 @@ def test_navigate_calendar_focused_not_in_calendar(self): """Test _navigate_calendar when focused widget is not in a CalendarWidget.""" self.app.state.active_page = "calendar:0" mock_btn = MagicMock(spec=ptg.Button) - self.app.manager.focused = mock_btn + self.app.main_window.selected = mock_btn # Container with a non-calendar widget real_container = ptg.Container(ptg.Label("test")) @@ -255,7 +263,7 @@ def test_navigate_calendar_target_not_calendar_widget(self): cal = CalendarWidget(2026, 5, MagicMock()) btn1 = cal.day_to_button[1] - self.app.manager.focused = btn1 + self.app.main_window.selected = btn1 # Container with the current calendar and a non-calendar widget real_container = ptg.Container(cal, ptg.Label("test")) @@ -275,7 +283,7 @@ def test_navigate_calendar_cross_widget(self): btn_may31 = cal1.day_to_button[31] btn_june1 = cal2.day_to_button[1] - self.app.manager.focused = btn_may31 + self.app.main_window.selected = btn_may31 real_container1 = ptg.Container(cal1) real_container2 = ptg.Container(cal2) @@ -286,7 +294,7 @@ def test_navigate_calendar_cross_widget(self): # 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) + self.assertEqual(self.app.main_window.selected, btn_june1) if __name__ == '__main__': diff --git a/tests/test_navigation_logic.py b/tests/test_navigation_logic.py index a648c2c..ffb6fbb 100644 --- a/tests/test_navigation_logic.py +++ b/tests/test_navigation_logic.py @@ -113,17 +113,50 @@ def test_go_to_next_day(self): self.app.go_to_next_day() self.assertEqual(self.app.state.current_date, initial_date + timedelta(days=1)) - def test_go_to_today(self): - """Test resetting current date to today.""" + def test_go_to_today_schedule(self): + """Test resetting current date to today from schedule view.""" + self.app.state.active_page = "schedule:2026-10-01" self.app.state.current_date = datetime(2026, 10, 1) - with patch.object(self.app, 'update_to_schedule'): + with patch.object(self.app, 'update_to_schedule') as mock_update: self.app.go_to_today() + mock_update.assert_called_once() # It should be today now self.assertEqual( self.app.state.current_date.strftime('%Y-%m-%d'), datetime.now().strftime('%Y-%m-%d') ) + def test_go_to_today_calendar(self): + """Test resetting current date to today from calendar view.""" + self.app.state.active_page = "calendar:0" + self.app.state.current_date = datetime(2026, 10, 1) + with patch.object(self.app, 'update_to_calendar') as mock_update: + self.app.go_to_today() + mock_update.assert_called_once() + # It should be today now + self.assertEqual( + self.app.state.current_date.strftime('%Y-%m-%d'), + datetime.now().strftime('%Y-%m-%d') + ) + + def test_go_to_previous_day_standings(self): + """Test go_to_previous_day while on standings screen (should do nothing).""" + self.app.state.active_page = "standings" + initial_date = self.app.state.current_date + with patch.object(self.app, 'update_to_schedule') as mock_update: + self.app.go_to_previous_day() + mock_update.assert_not_called() + self.assertEqual(self.app.state.current_date, initial_date) + + def test_go_to_next_day_standings(self): + """Test go_to_next_day while on standings screen (should do nothing).""" + self.app.state.active_page = "standings" + initial_date = self.app.state.current_date + with patch.object(self.app, 'update_to_schedule') as mock_update: + self.app.go_to_next_day() + mock_update.assert_not_called() + self.assertEqual(self.app.state.current_date, initial_date) + @patch('app.mlb_cli.StandingsScreen.get_widgets') def test_toggle_standings(self, mock_get): """Test transition to standings and back.""" diff --git a/tests/test_selection_persistence.py b/tests/test_selection_persistence.py new file mode 100644 index 0000000..adfcd80 --- /dev/null +++ b/tests/test_selection_persistence.py @@ -0,0 +1,45 @@ +""" +Test to verify that MLBWindow preserves selection on blur. +""" +import unittest +import pytermgui as ptg +from app.mlb_cli import MLBWindow + + +class TestSelectionPersistence(unittest.TestCase): + """Test cases for verifying selection persistence on MLBWindow blur.""" + + def test_mlb_window_preserves_selection_on_blur(self): + """Verify that MLBWindow.blur() does not deselect content.""" + window = MLBWindow() + btn = ptg.Button("Test") + window += btn + window.select(0) + + self.assertEqual(window.selected, btn) + + # Blur the window + window.blur() + + # Selection should persist + self.assertEqual(window.selected, btn) + self.assertFalse(window.has_focus) + + def test_standard_window_deselects_on_blur(self): + """Verify standard ptg.Window.blur() DOES deselect (baseline check).""" + window = ptg.Window() + btn = ptg.Button("Test") + window += btn + window.select(0) + + self.assertEqual(window.selected, btn) + + # Blur the window + window.blur() + + # Selection should be None + self.assertIsNone(window.selected) + + +if __name__ == '__main__': + unittest.main()