Files
cleveragents-core/features/steps/tui_app_coverage_steps.py
T
HAL9000 b588de18d6 fix(tui): rebase onto master and add CHANGELOG entry for prompt symbol fix (#6431)
Rebases the TUI prompt symbol fix onto the latest master, resolving
conflicts with the TextArea→Input refactor and the dollar-prefix shell
mode addition. Adds the missing CHANGELOG.md entry for #6431 and
removes the now-obsolete tui_prompt_textarea feature/steps that tested
the old TextArea-based implementation.

ISSUES CLOSED: #6431
2026-05-08 11:15:36 +00:00

523 lines
18 KiB
Python

"""Step definitions for tui_app_coverage.feature.
These steps target uncovered lines in cleveragents/tui/app.py:
- Lines 31-38: Textual import success path (mocked)
- Lines 81-100: _TextualCleverAgentsTuiApp class definition + __init__
- Lines 102-112: compose method
- Lines 114-121: on_mount method
- Lines 123-125: action_help method
- Lines 127-129: action_cycle_preset method
- Lines 131-142: _refresh_persona_bar method
- Lines 144-185: on_input_submitted (all branches)
- Line 189: CleverAgentsTuiApp alias
"""
import importlib
import os
import shutil
import sys
import tempfile
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import MagicMock, patch
from behave import given, then, when
# ---------------------------------------------------------------------------
# Mock Textual infrastructure
# ---------------------------------------------------------------------------
_MOCK_TEXTUAL_KEYS = [
"textual",
"textual.app",
"textual.containers",
"textual.widgets",
]
def _build_mock_textual():
"""Build mock textual modules that satisfy the app import gate."""
mock_textual = ModuleType("textual")
mock_textual_app = ModuleType("textual.app")
mock_textual_containers = ModuleType("textual.containers")
mock_textual_widgets = ModuleType("textual.widgets")
class MockApp:
"""Minimal App stand-in for the Textual base class."""
def __init__(self, *args, **kwargs):
self._widgets = {}
def query_one(self, selector, widget_type=None):
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, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
pass
class MockHeader:
def __init__(self, *args, **kwargs):
pass
class MockFooter:
def __init__(self, *args, **kwargs):
pass
class MockStatic:
def __init__(self, *args, **kwargs):
self._text = ""
def update(self, text):
self._text = text
class MockInput:
"""Minimal Input stand-in for the Textual base class."""
value = ""
def __init__(self, *args, **kwargs):
self.value = ""
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
mock_textual_widgets.Input = MockInput
return {
"textual": mock_textual,
"textual.app": mock_textual_app,
"textual.containers": mock_textual_containers,
"textual.widgets": mock_textual_widgets,
}
def _install_mock_textual(context):
"""Inject mock textual into sys.modules and reload the app module."""
mocks = _build_mock_textual()
context._tui_saved_modules = {}
for key in _MOCK_TEXTUAL_KEYS:
context._tui_saved_modules[key] = sys.modules.pop(key, None)
for key, mod in mocks.items():
sys.modules[key] = mod
# Reload widget modules so they pick up the mock Static/Input base class
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
import cleveragents.tui.widgets.persona_bar as pb_mod
import cleveragents.tui.widgets.prompt as prompt_mod
import cleveragents.tui.widgets.reference_picker as rp_mod
import cleveragents.tui.widgets.slash_command_overlay as sco_mod
importlib.reload(hp_mod)
importlib.reload(pb_mod)
importlib.reload(prompt_mod)
importlib.reload(rp_mod)
importlib.reload(sco_mod)
import cleveragents.tui.app as app_mod
importlib.reload(app_mod)
context._tui_app_mod = app_mod
context._tui_mock_static = mocks["textual.widgets"].Static
def _restore_modules(context):
"""Restore original sys.modules and reload the app module."""
for key, val in getattr(context, "_tui_saved_modules", {}).items():
if val is None:
sys.modules.pop(key, None)
else:
sys.modules[key] = val
# Reload widget modules so they pick up the real Static/Input base class again
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
import cleveragents.tui.widgets.persona_bar as pb_mod
import cleveragents.tui.widgets.prompt as prompt_mod
import cleveragents.tui.widgets.reference_picker as rp_mod
import cleveragents.tui.widgets.slash_command_overlay as sco_mod
importlib.reload(hp_mod)
importlib.reload(pb_mod)
importlib.reload(prompt_mod)
importlib.reload(rp_mod)
importlib.reload(sco_mod)
import cleveragents.tui.app as app_mod
importlib.reload(app_mod)
def _make_persona_state(context):
"""Create a real PersonaState backed by a temp directory."""
from cleveragents.tui.persona.registry import PersonaRegistry
from cleveragents.tui.persona.state import PersonaState
tmp = tempfile.mkdtemp()
context._tui_tmpdir = tmp
registry = PersonaRegistry(config_dir=Path(tmp))
registry.ensure_default()
return PersonaState(registry=registry)
def _cleanup_tmpdir(context):
tmp = getattr(context, "_tui_tmpdir", None)
if tmp:
shutil.rmtree(tmp, ignore_errors=True)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("the TUI app module is imported with mocked Textual")
def step_import_with_mock_textual(context):
"""Install mock Textual, reload app module, register cleanup."""
_install_mock_textual(context)
context.add_cleanup(lambda: _restore_modules(context))
context.add_cleanup(lambda: _cleanup_tmpdir(context))
# ---------------------------------------------------------------------------
# Module-level import gate (lines 31-38)
# ---------------------------------------------------------------------------
@then("the reloaded app module should have _TEXTUAL_AVAILABLE True")
def step_textual_available_true(context):
assert context._tui_app_mod._TEXTUAL_AVAILABLE is True
@then("the reloaded app module should expose _TextualCleverAgentsTuiApp")
def step_has_textual_class(context):
assert hasattr(context._tui_app_mod, "_TextualCleverAgentsTuiApp")
# ---------------------------------------------------------------------------
# textual_available function (lines 43-45)
# ---------------------------------------------------------------------------
@then("textual_available should return True on the reloaded module")
def step_textual_available_returns_true(context):
assert context._tui_app_mod.textual_available() is True
# ---------------------------------------------------------------------------
# SessionView (lines 48-54)
# ---------------------------------------------------------------------------
@when('I create a SessionView with session_id "{sid}" and transcript entries')
def step_create_session_view(context, sid):
context._tui_session_view = context._tui_app_mod.SessionView(
session_id=sid, transcript=["line1", "line2"]
)
@then('the SessionView session_id should be "{sid}"')
def step_session_view_id(context, sid):
assert context._tui_session_view.session_id == sid
@then("the SessionView transcript should contain {count:d} entries")
def step_session_view_transcript_count(context, count):
assert len(context._tui_session_view.transcript) == count
# ---------------------------------------------------------------------------
# _CommandRouter protocol (lines 56-61)
# ---------------------------------------------------------------------------
@when("I create a _CommandRouter implementation")
def step_create_command_router_impl(context):
class MyRouter:
def handle(self, raw, *, session_id):
return f"response:{raw}:{session_id}"
context._tui_router_impl = MyRouter()
@then("calling handle on the implementation should return a response")
def step_call_handle(context):
result = context._tui_router_impl.handle("test", session_id="s1")
assert result == "response:test:s1"
# ---------------------------------------------------------------------------
# App instantiation (lines 91-100)
# ---------------------------------------------------------------------------
class _FakeCommandRouter:
"""Test command router that returns predictable responses."""
def handle(self, raw, *, session_id):
return f"handled:{raw}"
@given("a mock command router and persona state")
def step_create_mock_deps(context):
context._tui_cmd_router = _FakeCommandRouter()
context._tui_persona_state = _make_persona_state(context)
@when("I instantiate the Textual TUI app")
def step_instantiate_app(context):
AppClass = context._tui_app_mod._ResolvedTuiApp
context._tui_app = AppClass(
command_router=context._tui_cmd_router,
persona_state=context._tui_persona_state,
)
@then('the app should have a _session with session_id "{sid}"')
def step_app_session_id(context, sid):
assert context._tui_app._session.session_id == sid
@then("the app should store the command router")
def step_app_has_router(context):
assert context._tui_app._command_router is context._tui_cmd_router
@then("the app should store the persona state")
def step_app_has_persona_state(context):
assert context._tui_app._persona_state is context._tui_persona_state
# ---------------------------------------------------------------------------
# Class variables (lines 84-89)
# ---------------------------------------------------------------------------
@then('the app class should have CSS_PATH set to "{path}"')
def step_css_path(context, path):
assert path == context._tui_app.CSS_PATH
@then("the app class should have {count:d} key bindings")
def step_bindings_count(context, count):
assert len(context._tui_app.BINDINGS) == count
# ---------------------------------------------------------------------------
# compose (lines 102-112)
# ---------------------------------------------------------------------------
@when("I call compose on the app")
def step_call_compose(context):
context._tui_compose_items = list(context._tui_app.compose())
@then("compose should yield at least {count:d} widgets")
def step_compose_count(context, count):
assert len(context._tui_compose_items) >= count
# ---------------------------------------------------------------------------
# on_mount (lines 114-121)
# ---------------------------------------------------------------------------
@when("I call on_mount on the app")
def step_call_on_mount(context):
context._tui_app.on_mount()
@then("the persona bar should have content set")
def step_persona_bar_has_content(context):
from cleveragents.tui.widgets.persona_bar import PersonaBar
bar = context._tui_app.query_one("#persona-bar", PersonaBar)
assert hasattr(bar, "_text")
assert bar._text # non-empty
@then("the help panel should be hidden on mount")
def step_help_panel_hidden_on_mount(context):
from cleveragents.tui.widgets.help_panel_overlay import HelpPanelOverlay
panel = context._tui_app.query_one("#help-panel", HelpPanelOverlay)
assert panel.visible is False
assert panel._text == ""
@then("the reference picker should have suggestions initialised")
def step_ref_picker_initialised(context):
from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay
picker = context._tui_app.query_one("#reference-picker", ReferencePickerOverlay)
assert hasattr(picker, "_text")
@then("the slash overlay should have commands initialised")
def step_slash_overlay_initialised(context):
from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay
overlay = context._tui_app.query_one("#slash-overlay", SlashCommandOverlay)
assert hasattr(overlay, "_text")
assert overlay._text # non-empty
# ---------------------------------------------------------------------------
# action_help (lines 123-125)
# ---------------------------------------------------------------------------
@when("I call action_help on the app")
def step_call_action_help(context):
context._tui_app.action_help()
@when('I set the prompt text to "{text}"')
def step_set_prompt_text(context, text):
from cleveragents.tui.widgets.prompt import PromptInput
prompt = context._tui_app.query_one("#prompt", PromptInput)
prompt.value = text
@then('the conversation widget should contain "{text}"')
def step_conversation_contains(context, text):
MockStatic = context._tui_mock_static
conv = context._tui_app.query_one("#conversation", MockStatic)
assert text in conv._text, f"Expected '{text}' in '{conv._text}'"
@then('the help panel text should contain "{text}"')
def step_help_panel_contains(context, text):
from cleveragents.tui.widgets.help_panel_overlay import HelpPanelOverlay
panel = context._tui_app.query_one("#help-panel", HelpPanelOverlay)
assert text in panel._text, f"Expected '{text}' in '{panel._text}'"
# ---------------------------------------------------------------------------
# action_cycle_preset (lines 127-129)
# ---------------------------------------------------------------------------
@when("I call action_cycle_preset on the app")
def step_call_action_cycle_preset(context):
context._tui_app.action_cycle_preset()
@then("the persona bar content should be refreshed")
def step_persona_bar_refreshed(context):
from cleveragents.tui.widgets.persona_bar import PersonaBar
bar = context._tui_app.query_one("#persona-bar", PersonaBar)
assert bar._text # non-empty after refresh
# ---------------------------------------------------------------------------
# _refresh_persona_bar (lines 131-142)
# ---------------------------------------------------------------------------
@when("I call _refresh_persona_bar on the app")
def step_call_refresh_persona_bar(context):
context._tui_app._refresh_persona_bar()
@then("the persona bar should display the persona name and scope")
def step_persona_bar_shows_name(context):
from cleveragents.tui.widgets.persona_bar import PersonaBar
bar = context._tui_app.query_one("#persona-bar", PersonaBar)
assert "default" in bar._text
assert "scope refs" in bar._text
# ---------------------------------------------------------------------------
# on_input_submitted helpers
# ---------------------------------------------------------------------------
def _submit_text(context, text):
"""Set prompt value and fire on_input_submitted."""
from cleveragents.tui.widgets.prompt import PromptInput
prompt = context._tui_app.query_one("#prompt", PromptInput)
prompt.value = text
event = SimpleNamespace()
context._tui_app.on_input_submitted(event)
# ---------------------------------------------------------------------------
# on_input_submitted: empty text (lines 144-150)
# ---------------------------------------------------------------------------
@when("I submit empty text to the app")
def step_submit_empty(context):
MockStatic = context._tui_mock_static
conv = context._tui_app.query_one("#conversation", MockStatic)
context._tui_conv_before = conv._text
_submit_text(context, "")
@then("the conversation widget should not be updated by input")
def step_conv_not_updated(context):
MockStatic = context._tui_mock_static
conv = context._tui_app.query_one("#conversation", MockStatic)
assert conv._text == context._tui_conv_before
# ---------------------------------------------------------------------------
# on_input_submitted: command mode (lines 152-167)
# ---------------------------------------------------------------------------
@when('I submit "{text}" to the app')
def step_submit_text(context, text):
os.environ["CLEVERAGENTS_ALLOW_DANGEROUS_SHELL"] = "1"
def restore_env():
os.environ.pop("CLEVERAGENTS_ALLOW_DANGEROUS_SHELL", None)
context.add_cleanup(restore_env)
_submit_text(context, text)
# ---------------------------------------------------------------------------
# on_input_submitted: shell returning None (lines 170-171)
# ---------------------------------------------------------------------------
@when("I submit shell text that produces a None shell result")
def step_submit_shell_none(context):
from cleveragents.tui.input.modes import InputMode, ModeResult
# We patch the InputModeRouter.process to return a shell result with None
with patch(
"cleveragents.tui.app.InputModeRouter.process",
return_value=ModeResult(
mode=InputMode.SHELL,
expanded_text="!nothing",
references=[],
shell_result=None,
command_result=None,
),
):
_submit_text(context, "!nothing")
# ---------------------------------------------------------------------------
# on_input_submitted: normal text with @ (lines 179-185)
# ---------------------------------------------------------------------------
@then("the reference picker should have been updated")
def step_ref_picker_updated(context):
from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay
picker = context._tui_app.query_one("#reference-picker", ReferencePickerOverlay)
assert hasattr(picker, "_text")
assert picker._text # updated with suggestions
# ---------------------------------------------------------------------------
# CleverAgentsTuiApp alias (line 189)
# ---------------------------------------------------------------------------
@then("CleverAgentsTuiApp should be _TextualCleverAgentsTuiApp on the reloaded module")
def step_alias_check(context):
assert (
context._tui_app_mod.CleverAgentsTuiApp is context._tui_app_mod._ResolvedTuiApp
)
assert (
context._tui_app_mod.CleverAgentsTuiApp.__name__ == "_TextualCleverAgentsTuiApp"
)
# ---------------------------------------------------------------------------
# THEME class variable (issue #4742)
# ---------------------------------------------------------------------------
@then('the app class should have THEME set to "{theme}"')
def step_theme_class_var(context, theme: str) -> None:
assert theme == context._tui_app.THEME, (
f"Expected THEME='{theme}', got '{context._tui_app.THEME}'"
)