Files
Xi Zhang e8399b7c94 fix(tui): window slash-command completions to terminal height (#362)
* feat(tui): implement completion popup rendering and windowing logic

* feat(tui): enhance completion popup with dynamic row budgeting and CSS adjustments

* Refactor picker widgets to use shared base class for improved code reuse

- Introduced `picker_base.py` to encapsulate common functionality for picker widgets.
- Updated `ModelPickerWidget`, `SkillBrowserWidget`, and `ThreadPickerWidget` to inherit from `PickerWidgetBase`.
- Implemented selection helpers (`first_selectable_index`, `move_selection`) in `picker_base.py` for consistent item navigation.
- Refactored rendering and selection logic in each widget to utilize the new base class methods.
- Added tests for picker functionality to ensure behavior remains consistent post-refactor.
2026-07-16 16:17:58 +01:00

256 lines
8.7 KiB
Python

"""Behavior tests for the picker/browser widgets and their shared engine.
Written against the pre-refactor SkillBrowserWidget / MCPBrowserWidget
behavior to pin the two-phase tag->checkbox flow, then kept green while
the widgets migrated onto the shared picker engine (PR #362 follow-up
discussion). Widgets are driven without a running app: ``__init__`` and
the ``action_*`` methods only touch widget state, and DOM-dependent
rendering no-ops before ``compose()``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import ClassVar
from unittest.mock import MagicMock
from EvoScientist.cli.widgets.mcp_browser import MCPBrowserWidget
from EvoScientist.cli.widgets.picker_base import (
first_selectable_index,
move_selection,
)
from EvoScientist.cli.widgets.skill_browser import SkillBrowserWidget
_SKILL_INDEX = [
{
"name": "paper-writing",
"description": "Write papers",
"tags": ["writing"],
"install_source": "src/paper-writing",
},
{
"name": "paper-review",
"description": "Review papers",
"tags": ["writing", "review"],
"install_source": "src/paper-review",
},
{
"name": "experiment-pipeline",
"description": "Run experiments",
"tags": ["experiments"],
"install_source": "src/experiment-pipeline",
},
]
@dataclass
class _Server:
name: str
tags: list[str] = field(default_factory=list)
description: str = ""
label: str = ""
_SERVERS = [
_Server("tavily", tags=["search"], description="Web search"),
_Server("github", tags=["dev", "search"], description="GitHub API"),
_Server("filesystem", tags=["dev"], description="Local files"),
]
def _skill_browser(**kwargs) -> SkillBrowserWidget:
w = SkillBrowserWidget(_SKILL_INDEX, {"paper-review"}, **kwargs)
w.post_message = MagicMock()
w.focus = MagicMock()
return w
def _mcp_browser(**kwargs) -> MCPBrowserWidget:
w = MCPBrowserWidget(_SERVERS, {"github"}, **kwargs)
w.post_message = MagicMock()
w.focus = MagicMock()
return w
class TestSelectionHelpers:
"""Pure navigation helpers shared by all pickers (headers skipped,
wrapping cursor)."""
_ITEMS: ClassVar[list[str]] = ["header", "item", "item", "header", "item"]
@staticmethod
def _selectable(item: str) -> bool:
return item == "item"
def test_first_selectable_skips_headers(self):
assert first_selectable_index(self._ITEMS, self._selectable) == 1
def test_first_selectable_empty_or_none_returns_zero(self):
assert first_selectable_index([], self._selectable) == 0
assert first_selectable_index(["header"], self._selectable) == 0
def test_move_skips_headers_and_wraps(self):
assert move_selection(self._ITEMS, 1, 1, self._selectable) == 2
assert move_selection(self._ITEMS, 2, 1, self._selectable) == 4
# wraps past the trailing header back to the first item
assert move_selection(self._ITEMS, 4, 1, self._selectable) == 1
assert move_selection(self._ITEMS, 1, -1, self._selectable) == 4
def test_move_with_no_selectable_keeps_current(self):
items = ["header", "header"]
assert move_selection(items, 0, 1, self._selectable) == 0
def test_move_single_selectable_stays_put(self):
items = ["header", "item"]
assert move_selection(items, 1, 1, self._selectable) == 1
class TestSkillBrowserFlow:
def test_tag_items_sorted_by_count_with_all_first(self):
w = _skill_browser()
assert w._tag_items[0] == ("all", 3)
# "writing" appears twice -> first among real tags
assert w._tag_items[1] == ("writing", 2)
def test_phase1_enter_filters_and_enters_phase2(self):
w = _skill_browser()
w._selected = 1 # "writing"
w.action_confirm()
assert w._phase == 2
assert [s["name"] for s in w._filtered_items] == [
"paper-writing",
"paper-review",
]
assert w._selected == 0
def test_phase1_all_selects_everything(self):
w = _skill_browser()
w._selected = 0 # "all"
w.action_confirm()
assert len(w._filtered_items) == 3
def test_space_toggles_uninstalled_only(self):
w = _skill_browser()
w.action_confirm() # all -> phase 2
w._selected = 0 # paper-writing (not installed)
w.action_toggle()
assert 0 in w._checked
w.action_toggle()
assert 0 not in w._checked
w._selected = 1 # paper-review (installed)
w.action_toggle()
assert 1 not in w._checked
def test_confirm_posts_confirmed_with_sources(self):
w = _skill_browser()
w.action_confirm() # all -> phase 2
w._selected = 0
w.action_toggle()
w._selected = 2
w.action_toggle()
w.action_confirm()
msg = w.post_message.call_args[0][0]
assert isinstance(msg, SkillBrowserWidget.Confirmed)
assert msg.install_sources == ["src/paper-writing", "src/experiment-pipeline"]
def test_escape_returns_to_phase1_then_cancels(self):
w = _skill_browser()
w.action_confirm() # phase 2
w.action_cancel()
assert w._phase == 1
w.action_cancel()
msg = w.post_message.call_args[0][0]
assert isinstance(msg, SkillBrowserWidget.Cancelled)
def test_pre_filter_tag_skips_to_phase2(self):
w = _skill_browser(pre_filter_tag="experiments")
assert w._phase == 2
assert [s["name"] for s in w._filtered_items] == ["experiment-pipeline"]
# Esc cancels directly (no tag-picker phase to return to)
w.action_cancel()
msg = w.post_message.call_args[0][0]
assert isinstance(msg, SkillBrowserWidget.Cancelled)
def test_pre_filter_without_match_falls_back_to_phase1(self):
w = _skill_browser(pre_filter_tag="nonexistent")
assert w._phase == 1
def test_move_wraps_around(self):
w = _skill_browser()
n = len(w._tag_items)
w._selected = 0
w.action_move_up()
assert w._selected == n - 1
w.action_move_down()
assert w._selected == 0
class TestMountedBrowser:
"""End-to-end smoke test: the engine's mount hook, bindings and
base-class CSS must all work on a real mounted widget."""
async def test_skill_browser_keyboard_flow(self):
from textual.app import App, ComposeResult
posted: list = []
class BrowserApp(App[None]):
def compose(self) -> ComposeResult:
yield SkillBrowserWidget(_SKILL_INDEX, {"paper-review"})
def on_skill_browser_widget_confirmed(self, event) -> None:
posted.append(event)
app = BrowserApp()
async with app.run_test(size=(80, 30)) as pilot:
widget = app.query_one(SkillBrowserWidget)
await pilot.pause()
# Base CSS (via TagCheckboxBrowserBase type selector) applied
assert widget.styles.border.top is not None
# Phase 1 renders the tag rows
assert widget._phase == 1
# Enter on "all" -> phase 2; space toggles; enter confirms
await pilot.press("enter")
assert widget._phase == 2
await pilot.press("space")
await pilot.press("enter")
await pilot.pause()
assert len(posted) == 1
assert posted[0].install_sources == ["src/paper-writing"]
class TestMCPBrowserFlow:
def test_tag_items_sorted_by_count_with_all_first(self):
w = _mcp_browser()
assert w._tag_items[0] == ("all", 3)
assert {t for t, _ in w._tag_items[1:]} == {"dev", "search"}
def test_confirm_posts_confirmed_with_entries(self):
w = _mcp_browser()
w.action_confirm() # all -> phase 2
w._selected = 0 # tavily (not installed)
w.action_toggle()
w._selected = 1 # github (installed -> ignored)
w.action_toggle()
w.action_confirm()
msg = w.post_message.call_args[0][0]
assert isinstance(msg, MCPBrowserWidget.Confirmed)
assert [e.name for e in msg.entries] == ["tavily"]
def test_tag_filter_selects_matching_servers(self):
w = _mcp_browser()
tag_index = [t for t, _ in w._tag_items].index("dev")
w._selected = tag_index
w.action_confirm()
assert w._phase == 2
assert [e.name for e in w._filtered_items] == ["github", "filesystem"]
def test_escape_returns_to_phase1_then_cancels(self):
w = _mcp_browser()
w.action_confirm()
w.action_cancel()
assert w._phase == 1
w.action_cancel()
msg = w.post_message.call_args[0][0]
assert isinstance(msg, MCPBrowserWidget.Cancelled)