Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions app/mlb_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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):
Expand All @@ -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,
Expand Down
34 changes: 21 additions & 13 deletions tests/test_calendar_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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])
Expand All @@ -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])
Expand All @@ -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
Expand All @@ -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"))
Expand All @@ -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"))
Expand All @@ -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)
Expand All @@ -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__':
Expand Down
39 changes: 36 additions & 3 deletions tests/test_navigation_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
45 changes: 45 additions & 0 deletions tests/test_selection_persistence.py
Original file line number Diff line number Diff line change
@@ -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()
Loading