"""Step definitions for tui_first_run.feature. Covers: - is_first_run helper - create_default_persona_for_actor helper - render_actor_selection helper - ActorSelectionOverlay widget - TUI app integration (on_mount first-run detection, _complete_first_run) """ from __future__ import annotations import sys import tempfile from pathlib import Path from types import ModuleType from unittest.mock import MagicMock from behave import given, then, when # --------------------------------------------------------------------------- # Shared mock-Textual infrastructure (mirrors tui_app_coverage_steps.py) # --------------------------------------------------------------------------- _MOCK_TEXTUAL_KEYS = [ "textual", "textual.app", "textual.containers", "textual.widgets", ] def _build_mock_textual() -> dict[str, ModuleType]: mock_textual = ModuleType("textual") mock_textual_app = ModuleType("textual.app") mock_textual_containers = ModuleType("textual.containers") mock_textual_widgets = ModuleType("textual.widgets") class MockApp: def __init__(self, *args: object, **kwargs: object) -> None: self._widgets: dict[str, object] = {} def query_one(self, selector: str, widget_type: type | None = None) -> object: if selector in self._widgets: return self._widgets[selector] if widget_type is not None: widget = widget_type(id=selector.lstrip("#")) self._widgets[selector] = widget return widget return MagicMock() class MockVertical: def __init__(self, *args: object, **kwargs: object) -> None: pass def __enter__(self) -> MockVertical: return self def __exit__(self, *args: object) -> None: pass class MockHeader: def __init__(self, *args: object, **kwargs: object) -> None: pass class MockFooter: def __init__(self, *args: object, **kwargs: object) -> None: pass class MockStatic: def __init__(self, *args: object, **kwargs: object) -> None: self._text = "" def update(self, text: str) -> None: self._text = text mock_textual_app.App = MockApp mock_textual_containers.Vertical = MockVertical mock_textual_widgets.Header = MockHeader mock_textual_widgets.Footer = MockFooter mock_textual_widgets.Static = MockStatic return { "textual": mock_textual, "textual.app": mock_textual_app, "textual.containers": mock_textual_containers, "textual.widgets": mock_textual_widgets, } def _reload_tui_modules(mock_modules: dict[str, ModuleType]) -> None: """Reload TUI modules with mocked Textual in sys.modules.""" tui_keys = [ key for key in list(sys.modules.keys()) if key.startswith("cleveragents.tui") ] for key in tui_keys: del sys.modules[key] for name, mod in mock_modules.items(): sys.modules[name] = mod def _restore_textual(original: dict[str, object]) -> None: for key in _MOCK_TEXTUAL_KEYS: if key in original: sys.modules[key] = original[key] # type: ignore[assignment] else: sys.modules.pop(key, None) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_registry(tmp_dir: Path) -> object: from cleveragents.tui.persona.registry import PersonaRegistry return PersonaRegistry(config_dir=tmp_dir) # --------------------------------------------------------------------------- # is_first_run # --------------------------------------------------------------------------- @given("an empty persona registry") def step_empty_registry(context: object) -> None: tmp = tempfile.mkdtemp() context._tmp_dir = Path(tmp) context._registry = _make_registry(context._tmp_dir) @given('a persona registry with a default persona using actor "{actor}"') def step_registry_with_persona(context: object, actor: str) -> None: tmp = tempfile.mkdtemp() context._tmp_dir = Path(tmp) context._registry = _make_registry(context._tmp_dir) from cleveragents.tui.persona.schema import Persona persona = Persona(name="default", actor=actor, description="test") context._registry.save(persona) @then("is_first_run should return True") def step_is_first_run_true(context: object) -> None: from cleveragents.tui.first_run import is_first_run assert is_first_run(context._registry) is True @then("is_first_run should return False") def step_is_first_run_false(context: object) -> None: from cleveragents.tui.first_run import is_first_run assert is_first_run(context._registry) is False # --------------------------------------------------------------------------- # create_default_persona_for_actor # --------------------------------------------------------------------------- @when('I call create_default_persona_for_actor with actor "{actor}"') def step_create_default_persona(context: object, actor: str) -> None: from cleveragents.tui.first_run import create_default_persona_for_actor context._created_persona = create_default_persona_for_actor( context._registry, actor ) @then('the registry should contain a persona named "{name}"') def step_registry_has_persona(context: object, name: str) -> None: persona = context._registry.get(name) assert persona is not None, f"Expected persona '{name}' in registry" @then('the default persona actor should be "{actor}"') def step_default_persona_actor(context: object, actor: str) -> None: persona = context._registry.get("default") assert persona is not None assert persona.actor == actor, f"Expected actor '{actor}', got '{persona.actor}'" @then('the registry last persona should be "{name}"') def step_registry_last_persona(context: object, name: str) -> None: last = context._registry.get_last_persona() assert last == name, f"Expected last persona '{name}', got '{last}'" # --------------------------------------------------------------------------- # render_actor_selection # --------------------------------------------------------------------------- @when("I render the actor selection with default actors") def step_render_default(context: object) -> None: from cleveragents.tui.widgets.actor_selection_overlay import ( _DEFAULT_ACTORS, render_actor_selection, ) context._rendered = render_actor_selection(_DEFAULT_ACTORS, 0, "") @when("I render the actor selection with selected index 2") def step_render_selected_index(context: object) -> None: from cleveragents.tui.widgets.actor_selection_overlay import ( _DEFAULT_ACTORS, render_actor_selection, ) context._rendered = render_actor_selection(_DEFAULT_ACTORS, 2, "") @then("the rendered text should contain a cursor marker") def step_rendered_contains_cursor(context: object) -> None: # The cursor character is the heavy right-pointing angle quotation mark # used as a selection indicator per the spec mockup. cursor = "\u276f" assert cursor in context._rendered, ( f"Expected cursor marker in rendered text:\n{context._rendered}" ) @when('I render the actor selection with search query "{query}"') def step_render_with_query(context: object, query: str) -> None: from cleveragents.tui.widgets.actor_selection_overlay import ( _DEFAULT_ACTORS, render_actor_selection, ) context._rendered = render_actor_selection(_DEFAULT_ACTORS, 0, query) @then('the rendered text should contain "{text}"') def step_rendered_contains(context: object, text: str) -> None: assert text in context._rendered, ( f"Expected '{text}' in rendered text:\n{context._rendered}" ) # --------------------------------------------------------------------------- # ActorSelectionOverlay widget # --------------------------------------------------------------------------- @given("a new ActorSelectionOverlay") def step_new_overlay(context: object) -> None: from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay context._overlay = ActorSelectionOverlay() @when("I call show on the overlay") def step_overlay_show(context: object) -> None: context._overlay.show() @when("I call hide on the overlay") def step_overlay_hide(context: object) -> None: context._overlay.hide() @when("I call show on the overlay with actors {actors_json}") def step_overlay_show_custom(context: object, actors_json: str) -> None: import json actors = json.loads(actors_json) context._overlay.show(actors=actors) @when("I call move_down on the overlay") def step_overlay_move_down(context: object) -> None: context._overlay.move_down() @when("I call move_up on the overlay") def step_overlay_move_up(context: object) -> None: context._overlay.move_up() @when("I move_down past the last actor") def step_overlay_move_down_past_last(context: object) -> None: count = len(context._overlay.filtered_actors) for _ in range(count): context._overlay.move_down() @when('I call set_search with query "{query}"') def step_overlay_set_search(context: object, query: str) -> None: context._overlay.set_search(query) @when('I call set_search with query ""') def step_overlay_set_search_empty(context: object) -> None: context._overlay.set_search("") @when("I call confirm on the overlay") def step_overlay_confirm(context: object) -> None: context._confirmed_result = context._overlay.confirm() @then("the overlay should be visible") def step_overlay_visible(context: object) -> None: assert context._overlay.visible is True @then("the overlay should not be visible") def step_overlay_not_visible(context: object) -> None: assert context._overlay.visible is False @then('the overlay actors list should contain "{actor}"') def step_overlay_actors_contains(context: object, actor: str) -> None: assert actor in context._overlay.actors, ( f"Expected '{actor}' in actors: {context._overlay.actors}" ) @then("the overlay selected index should be 1") def step_overlay_selected_1(context: object) -> None: assert context._overlay.selected_index == 1, ( f"Expected 1, got {context._overlay.selected_index}" ) @then("the overlay selected index should be the last index") def step_overlay_selected_last(context: object) -> None: last = len(context._overlay.filtered_actors) - 1 assert context._overlay.selected_index == last, ( f"Expected {last}, got {context._overlay.selected_index}" ) @then("the overlay selected index should be 0") def step_overlay_selected_0(context: object) -> None: assert context._overlay.selected_index == 0, ( f"Expected 0, got {context._overlay.selected_index}" ) @then('the overlay filtered actors should only contain actors matching "{query}"') def step_overlay_filtered_match(context: object, query: str) -> None: for actor in context._overlay.filtered_actors: assert query.lower() in actor.lower(), ( f"Actor '{actor}' does not match query '{query}'" ) @then("the overlay filtered actors count should equal the full actors count") def step_overlay_filtered_count_full(context: object) -> None: assert len(context._overlay.filtered_actors) == len(context._overlay.actors), ( f"Filtered: {len(context._overlay.filtered_actors)}, " f"Full: {len(context._overlay.actors)}" ) @then('the confirmed actor should be "{actor}"') def step_confirmed_actor(context: object, actor: str) -> None: assert context._confirmed_result == actor, ( f"Expected '{actor}', got '{context._confirmed_result}'" ) @then("the confirmed actor should be None") def step_confirmed_actor_none(context: object) -> None: assert context._confirmed_result is None, ( f"Expected None, got '{context._confirmed_result}'" ) @then("the overlay confirmed flag should be True") def step_overlay_confirmed_flag(context: object) -> None: assert context._overlay.confirmed is True @then("the overlay selected_actor should be None") def step_overlay_selected_actor_none(context: object) -> None: assert context._overlay.selected_actor is None @then('the overlay search_query should be ""') def step_overlay_search_query_empty(context: object) -> None: assert context._overlay.search_query == "", ( f"Expected empty string, got '{context._overlay.search_query}'" ) # --------------------------------------------------------------------------- # TUI app integration # --------------------------------------------------------------------------- def _build_first_run_app(context: object, *, has_personas: bool) -> None: """Build a mocked TUI app for first-run integration tests.""" tmp = tempfile.mkdtemp() context._tmp_dir = Path(tmp) original = {k: sys.modules[k] for k in _MOCK_TEXTUAL_KEYS if k in sys.modules} # Save all cleveragents.tui.* modules before deleting them so we can # restore them after the test (prevents module re-import issues in # subsequent tests that patch cleveragents.tui.commands.get_container). original_tui = { k: sys.modules[k] for k in list(sys.modules.keys()) if k.startswith("cleveragents.tui") } mock_modules = _build_mock_textual() _reload_tui_modules(mock_modules) def _cleanup() -> None: _restore_textual(original) # Restore cleveragents.tui.* modules to prevent re-import issues for key, mod in original_tui.items(): sys.modules[key] = mod context.add_cleanup(_cleanup) from cleveragents.tui.persona.registry import PersonaRegistry from cleveragents.tui.persona.schema import Persona from cleveragents.tui.persona.state import PersonaState registry = PersonaRegistry(config_dir=context._tmp_dir) if has_personas: persona = Persona(name="default", actor="local/mock", description="test") registry.save(persona) context._registry = registry persona_state = PersonaState(registry=registry) class _MockRouter: def handle(self, raw: str, *, session_id: str) -> str: return f"handled:{raw}" from cleveragents.tui.app import CleverAgentsTuiApp app = CleverAgentsTuiApp(command_router=_MockRouter(), persona_state=persona_state) context._first_run_app = app @given("the TUI app is initialised with an empty persona registry") def step_app_empty_registry(context: object) -> None: _build_first_run_app(context, has_personas=False) @given("the TUI app is initialised with an existing persona registry") def step_app_existing_registry(context: object) -> None: _build_first_run_app(context, has_personas=True) @when("I call on_mount on the first-run app") def step_app_on_mount(context: object) -> None: context._first_run_app.on_mount() @then("the actor selection overlay should be visible") def step_actor_overlay_visible(context: object) -> None: from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay overlay = context._first_run_app.query_one( "#actor-selection", ActorSelectionOverlay ) assert overlay.visible is True, "Expected actor selection overlay to be visible" @then("the actor selection overlay should not be visible") def step_actor_overlay_not_visible(context: object) -> None: from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay overlay = context._first_run_app.query_one( "#actor-selection", ActorSelectionOverlay ) assert overlay.visible is False, "Expected actor selection overlay to be hidden" @when('I call _complete_first_run with actor "{actor}"') def step_complete_first_run(context: object, actor: str) -> None: context._first_run_app._complete_first_run(actor) @then("the persona bar should reflect the new actor") def step_persona_bar_reflects_actor(context: object) -> None: from cleveragents.tui.widgets.persona_bar import PersonaBar bar = context._first_run_app.query_one("#persona-bar", PersonaBar) assert "anthropic/claude-4-sonnet" in bar._text, ( f"Expected actor in persona bar, got: {bar._text}" )