e8399b7c94
* 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.
256 lines
8.7 KiB
Python
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)
|