66bd3bf0cf
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 1m0s
CI / build (pull_request) Successful in 27s
CI / quality (pull_request) Successful in 1m19s
CI / helm (pull_request) Successful in 34s
CI / security (pull_request) Successful in 1m26s
CI / typecheck (pull_request) Successful in 1m29s
CI / push-validation (pull_request) Successful in 20s
CI / integration_tests (pull_request) Successful in 4m33s
CI / e2e_tests (pull_request) Successful in 4m29s
CI / unit_tests (pull_request) Successful in 4m49s
CI / docker (pull_request) Successful in 1m28s
CI / coverage (pull_request) Successful in 12m16s
CI / status-check (pull_request) Successful in 3s
CI / benchmark-publish (push) Failing after 56s
CI / build (push) Successful in 59s
CI / lint (push) Successful in 1m20s
CI / push-validation (push) Successful in 35s
CI / typecheck (push) Successful in 1m27s
CI / quality (push) Successful in 1m25s
CI / helm (push) Successful in 44s
CI / security (push) Successful in 1m38s
CI / integration_tests (push) Successful in 3m45s
CI / e2e_tests (push) Successful in 3m51s
CI / unit_tests (push) Successful in 5m14s
CI / docker (push) Successful in 1m56s
CI / coverage (push) Successful in 11m0s
CI / status-check (push) Successful in 4s
The _TextualCleverAgentsTuiApp class was missing the THEME class variable, causing Textual to use its default textual-dark theme instead of the Dracula theme required by the spec (docs/specification.md §TUI Theme). Added THEME: ClassVar[str] = "dracula" to the class and a corresponding BDD scenario tagged @tdd_issue @tdd_issue_4742 to verify the fix. ISSUES CLOSED: #4742
523 lines
18 KiB
Python
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 MockTextArea:
|
|
"""Minimal TextArea stand-in for the Textual base class."""
|
|
|
|
text = ""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.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
|
|
mock_textual_widgets.TextArea = MockTextArea
|
|
|
|
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/TextArea 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/TextArea 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.text = 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 text and fire on_input_submitted."""
|
|
from cleveragents.tui.widgets.prompt import PromptInput
|
|
|
|
prompt = context._tui_app.query_one("#prompt", PromptInput)
|
|
prompt.text = 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}'"
|
|
)
|