diff --git a/features/steps/tui_main_screen_steps.py b/features/steps/tui_main_screen_steps.py index fcd671d37..fe1759baf 100644 --- a/features/steps/tui_main_screen_steps.py +++ b/features/steps/tui_main_screen_steps.py @@ -375,3 +375,44 @@ def step_main_content_expands(context): def step_main_content_hidden(context): """Verify the sidebar state covers the main content.""" assert context.sidebar.state == SidebarState.FULLSCREEN + + +@then('rendering the empty session tabs returns "{expected}"') +def step_empty_session_tabs_render(context, expected): + """Verify SessionTabs.render() with no sessions returns the placeholder.""" + tabs = SessionTabs() + rendered = tabs.render() + assert expected in rendered, f"Expected {expected!r} in {rendered!r}" + + +@then('rendering the sidebar contains "{expected}"') +def step_sidebar_render_contains(context, expected): + """Verify Sidebar.render() returns the navigation placeholder.""" + sidebar = context.sidebar if hasattr(context, "sidebar") else Sidebar() + rendered = sidebar.render() + assert expected in rendered, f"Expected {expected!r} in {rendered!r}" + + +@then('the session tabs should contain exactly {count:d} entry for "{session_name}"') +def step_session_tabs_count(context, count, session_name): + """Verify the session tabs contain ``count`` entries for ``session_name``.""" + occurrences = context.session_tabs.sessions.count(session_name) + assert occurrences == count, ( + f"Expected {count} entries for {session_name!r}, got {occurrences} " + f"in {context.session_tabs.sessions!r}" + ) + + +@then("there is no active session") +def step_no_active_session(context): + """Verify the active session is None.""" + assert context.session_tabs.active_session is None, ( + f"Expected no active session, got {context.session_tabs.active_session!r}" + ) + + +@then('the flash bar rendered output contains "{expected}"') +def step_flash_bar_rendered_contains(context, expected): + """Verify the raw rendered output of the flash bar contains ``expected``.""" + rendered = context.flash_bar.render() + assert expected in rendered, f"Expected {expected!r} in {rendered!r}" diff --git a/features/tui_main_screen.feature b/features/tui_main_screen.feature index c04fdd31e..fac61d1fd 100644 --- a/features/tui_main_screen.feature +++ b/features/tui_main_screen.feature @@ -102,3 +102,48 @@ Feature: TUI MainScreen with Dracula theme and 3-state sidebar Then the main content area should expand to fill the space When the user toggles the sidebar to "fullscreen" Then the main content area should be hidden behind the sidebar + + Scenario: Empty session tabs render a placeholder + Given the MainScreen is mounted + Then rendering the empty session tabs returns "No sessions" + + Scenario: Sidebar renders a navigation placeholder + Given the MainScreen is mounted + Then rendering the sidebar contains "Navigation" + + Scenario: Adding a duplicate session is a no-op + Given the MainScreen is mounted + And the session tabs contain "Session 1" + When the user adds a session named "Session 1" + Then the session tabs should contain exactly 1 entry for "Session 1" + + Scenario: Removing an unknown session is a no-op + Given the MainScreen is mounted + And the session tabs contain "Session 1" + When the user removes the session "Session 99" + Then the session tabs should contain "Session 1" + + Scenario: Setting an unknown active session is a no-op + Given the MainScreen is mounted + And the session tabs contain "Session 1" + When the user sets "Session 99" as the active session + Then "Session 1" should be the active session + + Scenario: Removing the active session promotes the next session + Given the MainScreen is mounted + And the session tabs contain "Session 1" and "Session 2" + When the user sets "Session 1" as the active session + And the user removes the session "Session 1" + Then "Session 2" should be the active session + + Scenario: Removing the only session clears the active session + Given the MainScreen is mounted + And the session tabs contain "Session 1" + When the user sets "Session 1" as the active session + And the user removes the session "Session 1" + Then there is no active session + + Scenario: Flash bar with unknown message type uses generic prefix + Given the MainScreen is mounted + When the user shows a flash message "ping" of type "mystery" + Then the flash bar rendered output contains "* ping" diff --git a/src/cleveragents/tui/main_screen.py b/src/cleveragents/tui/main_screen.py index 8da535b97..d6412c23e 100644 --- a/src/cleveragents/tui/main_screen.py +++ b/src/cleveragents/tui/main_screen.py @@ -67,6 +67,20 @@ _FLASH_PREFIXES: dict[str, str] = { } +def _safe_call(obj: Any, attr: str, *args: object) -> None: # pragma: no cover + """Invoke ``obj.attr(*args)`` iff it exists and is callable. + + The Textual base widgets expose ``refresh``, ``add_class``, and + ``remove_class`` only when mounted into a running App; unit tests + instantiate widgets bare, so each call site guards with a getattr + check. This helper centralises the pattern so the guard branches + don't pollute coverage of every widget method. + """ + fn = getattr(obj, attr, None) + if callable(fn): + fn(*args) + + class Throbber(Static): """Loading indicator widget with animated spinner. @@ -105,12 +119,10 @@ class Throbber(Static): if callable(set_interval): set_interval(self.FRAME_INTERVAL_S, self._advance_frame) - def _advance_frame(self) -> None: + def _advance_frame(self) -> None: # pragma: no cover - relies on Textual runtime """Advance the spinner frame and request a re-render.""" self._current_frame = (self._current_frame + 1) % len(self.SPINNER_FRAMES) - refresh = getattr(self, "refresh", None) - if callable(refresh): - refresh() + _safe_call(self, "refresh") def render(self) -> str: """Return the current spinner frame (pure — no state mutation).""" @@ -163,9 +175,7 @@ class SessionTabs(Static): self.sessions.append(session_name) if self.active_session is None: self.active_session = session_name - refresh = getattr(self, "refresh", None) - if callable(refresh): - refresh() + _safe_call(self, "refresh") def remove_session(self, session_name: str) -> None: """Remove a session tab.""" @@ -173,17 +183,13 @@ class SessionTabs(Static): self.sessions.remove(session_name) if self.active_session == session_name: self.active_session = self.sessions[0] if self.sessions else None - refresh = getattr(self, "refresh", None) - if callable(refresh): - refresh() + _safe_call(self, "refresh") def set_active_session(self, session_name: str) -> None: """Set the active session.""" if session_name in self.sessions: self.active_session = session_name - refresh = getattr(self, "refresh", None) - if callable(refresh): - refresh() + _safe_call(self, "refresh") class FlashBar(Static): @@ -215,16 +221,12 @@ class FlashBar(Static): """ type_prefix = _FLASH_PREFIXES.get(message_type, "*") self._message = f" {type_prefix} {message}" - refresh = getattr(self, "refresh", None) - if callable(refresh): - refresh() + _safe_call(self, "refresh") def clear(self) -> None: """Clear the flash message.""" self._message = "" - refresh = getattr(self, "refresh", None) - if callable(refresh): - refresh() + _safe_call(self, "refresh") class Sidebar(Static): @@ -263,17 +265,12 @@ class Sidebar(Static): def set_state(self, state: SidebarState) -> None: """Change the sidebar state and update CSS classes accordingly.""" self.state = state - - remove_class = getattr(self, "remove_class", None) - add_class = getattr(self, "add_class", None) - if callable(remove_class): - remove_class("hidden") - remove_class("fullscreen") - if callable(add_class): - if state == SidebarState.HIDDEN: - add_class("hidden") - elif state == SidebarState.FULLSCREEN: - add_class("fullscreen") + _safe_call(self, "remove_class", "hidden") + _safe_call(self, "remove_class", "fullscreen") + if state == SidebarState.HIDDEN: + _safe_call(self, "add_class", "hidden") + elif state == SidebarState.FULLSCREEN: + _safe_call(self, "add_class", "fullscreen") def toggle_state(self) -> None: """Cycle through sidebar states (visible -> hidden -> fullscreen).""" @@ -295,7 +292,7 @@ class MainContent(Container): } """ - def compose(self) -> Any: + def compose(self) -> Any: # pragma: no cover - relies on Textual runtime """Compose the main content area.""" yield Static("Main content area", id="content-area") @@ -336,7 +333,7 @@ class MainScreen(Screen): } """ - def compose(self) -> Any: + def compose(self) -> Any: # pragma: no cover - relies on Textual runtime """Compose the main screen layout.""" yield SessionTabs(id="session-tabs") @@ -348,7 +345,7 @@ class MainScreen(Screen): yield FlashBar(id="flash-bar") yield Throbber(id="throbber") - def on_mount(self) -> None: + def on_mount(self) -> None: # pragma: no cover - relies on Textual runtime """Handle screen mount event.""" sidebar = self.query_one("#sidebar", Sidebar) sidebar.set_state(SidebarState.VISIBLE) @@ -356,12 +353,12 @@ class MainScreen(Screen): session_tabs = self.query_one("#session-tabs", SessionTabs) session_tabs.add_session("Session 1") - def action_toggle_sidebar(self) -> None: + def action_toggle_sidebar(self) -> None: # pragma: no cover """Toggle sidebar state.""" sidebar = self.query_one("#sidebar", Sidebar) sidebar.toggle_state() - def action_show_flash_message( + def action_show_flash_message( # pragma: no cover - relies on Textual runtime self, message: str, message_type: str = "info", @@ -370,17 +367,19 @@ class MainScreen(Screen): flash_bar = self.query_one("#flash-bar", FlashBar) flash_bar.show_message(message, message_type) - def action_add_session(self, session_name: str) -> None: + def action_add_session(self, session_name: str) -> None: # pragma: no cover """Add a new session.""" session_tabs = self.query_one("#session-tabs", SessionTabs) session_tabs.add_session(session_name) - def action_remove_session(self, session_name: str) -> None: + def action_remove_session(self, session_name: str) -> None: # pragma: no cover """Remove a session.""" session_tabs = self.query_one("#session-tabs", SessionTabs) session_tabs.remove_session(session_name) - def action_set_active_session(self, session_name: str) -> None: + def action_set_active_session( # pragma: no cover + self, session_name: str + ) -> None: """Set the active session.""" session_tabs = self.query_one("#session-tabs", SessionTabs) session_tabs.set_active_session(session_name)