f1213e28c8
Add a dedicated AI tab with provider Select, model Input, base URL Input, and masked API key Input (write-only, stored in vault). Switching providers reactively updates the model placeholder, base URL default, and shows/hides the API key row for cloud vs. local providers. ctrl+s saves config and vault. Extend GENERAL_FIELDS with Memory, Security, Plugin, and Daemon sections using a new "section" header type and optional int cast for numeric fields. _CoreField gains cast: type | None for automatic value coercion on save. Add 5 new tests covering AI tab rendering, config save, vault key write, vault key skip-on-empty, and section header rendering. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
265 lines
8.6 KiB
Python
265 lines
8.6 KiB
Python
import json
|
|
from unittest.mock import patch
|
|
|
|
from textual.widgets import DataTable, Input, Label, Select, Switch, TabbedContent
|
|
|
|
from pyra.config.schema import ProviderConfig, PyraConfig
|
|
|
|
|
|
def _make_cfg():
|
|
return PyraConfig(ai=ProviderConfig(provider_id="ollama", model="test"))
|
|
|
|
|
|
# ── Pure helper functions (no Textual, no fixtures) ───────────────────────────
|
|
|
|
def test_get_nested_one_level():
|
|
from pyra.config.tui import _get_nested
|
|
|
|
class Obj:
|
|
value = 42
|
|
|
|
assert _get_nested(Obj(), "value") == 42
|
|
|
|
|
|
def test_get_nested_two_levels():
|
|
from pyra.config.tui import _get_nested
|
|
|
|
cfg = _make_cfg()
|
|
assert _get_nested(cfg, "general.user_name") == "User"
|
|
assert _get_nested(cfg, "daemon.enabled") is False
|
|
|
|
|
|
def test_set_nested_two_levels():
|
|
from pyra.config.tui import _set_nested
|
|
|
|
cfg = _make_cfg()
|
|
_set_nested(cfg, "general.user_name", "Alice")
|
|
assert cfg.general.user_name == "Alice"
|
|
|
|
|
|
def test_set_nested_bool():
|
|
from pyra.config.tui import _set_nested
|
|
|
|
cfg = _make_cfg()
|
|
_set_nested(cfg, "daemon.enabled", True)
|
|
assert cfg.daemon.enabled is True
|
|
|
|
|
|
def test_fid_replaces_dots():
|
|
from pyra.config.tui import _fid
|
|
|
|
assert _fid("general.user_name") == "f-general-user_name"
|
|
assert _fid("daemon.enabled") == "f-daemon-enabled"
|
|
|
|
|
|
def test_pfid_format():
|
|
from pyra.config.tui import _pfid
|
|
|
|
assert _pfid("myplugin", "api_url") == "pf-myplugin-api_url"
|
|
assert _pfid("my-plugin", "some_key") == "pf-my-plugin-some_key"
|
|
|
|
|
|
def test_general_fields_non_empty():
|
|
from pyra.config.tui import GENERAL_FIELDS
|
|
|
|
assert len(GENERAL_FIELDS) >= 3
|
|
|
|
|
|
def test_general_fields_all_valid_types():
|
|
from pyra.config.tui import GENERAL_FIELDS
|
|
|
|
valid_types = {"text", "bool", "select", "section"}
|
|
for f in GENERAL_FIELDS:
|
|
assert f.type in valid_types, f"Field '{f.path}' has unexpected type '{f.type}'"
|
|
|
|
|
|
# ── Textual ConfigApp ─────────────────────────────────────────────────────────
|
|
|
|
async def test_config_app_renders_all_general_fields(tmp_pyra_home):
|
|
from pyra.config.manager import save_config
|
|
from pyra.config.tui import GENERAL_FIELDS, ConfigApp, _fid
|
|
|
|
save_config(_make_cfg())
|
|
async with ConfigApp().run_test() as pilot:
|
|
for f in GENERAL_FIELDS:
|
|
if f.type == "section":
|
|
continue
|
|
wid = _fid(f.path)
|
|
if f.type == "bool":
|
|
assert pilot.app.query_one(f"#{wid}", Switch)
|
|
else:
|
|
assert pilot.app.query_one(f"#{wid}", Input)
|
|
|
|
|
|
async def test_general_tab_save_persists_new_value(tmp_pyra_home):
|
|
from textual.app import App as TextualApp, ComposeResult as CR
|
|
|
|
from pyra.config.manager import save_config as initial_save
|
|
from pyra.config.tui import _GeneralTab, _fid
|
|
|
|
initial_save(_make_cfg())
|
|
|
|
# Test _GeneralTab in isolation — avoids TabbedContent click-routing complexity
|
|
class _TestApp(TextualApp):
|
|
def compose(self) -> CR:
|
|
yield _GeneralTab()
|
|
|
|
saved = []
|
|
with patch("pyra.config.tui.save_config", side_effect=lambda c: saved.append(c)):
|
|
async with _TestApp().run_test() as pilot:
|
|
widget = pilot.app.query_one(f"#{_fid('general.user_name')}", Input)
|
|
widget.value = "Alice"
|
|
await pilot.pause() # flush reactive update before key press
|
|
await pilot.press("ctrl+s")
|
|
|
|
assert saved, "save_config was not called"
|
|
assert saved[-1].general.user_name == "Alice"
|
|
|
|
|
|
async def test_plugins_tab_shows_installed_plugin(tmp_pyra_home):
|
|
from pyra.config.manager import save_config
|
|
from pyra.config.tui import ConfigApp
|
|
|
|
save_config(_make_cfg())
|
|
plugin_dir = tmp_pyra_home / "plugins" / "testplugin"
|
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
(plugin_dir / "manifest.json").write_text(
|
|
json.dumps({"name": "testplugin", "version": "1.0", "description": "Test plugin"})
|
|
)
|
|
|
|
async with ConfigApp().run_test() as pilot:
|
|
table = pilot.app.query_one("#plugins-table", DataTable)
|
|
assert table.row_count == 1
|
|
|
|
|
|
async def test_plugin_config_tab_appears_for_plugin_with_config_fields(tmp_pyra_home):
|
|
from pyra.config.manager import save_config
|
|
from pyra.config.tui import ConfigApp
|
|
from pyra.plugins.base import BasePlugin, ConfigField
|
|
|
|
save_config(_make_cfg())
|
|
|
|
class FakePlugin(BasePlugin):
|
|
name = "fake"
|
|
description = "A fake plugin"
|
|
version = "1.0"
|
|
|
|
def config_fields(self):
|
|
return [ConfigField("url", "URL", "text", "http://example.com")]
|
|
|
|
fake_entries = [("fake", {"name": "fake", "version": "1.0"}, FakePlugin())]
|
|
with patch("pyra.config.tui._installed_plugins", return_value=fake_entries):
|
|
async with ConfigApp().run_test() as pilot:
|
|
content = pilot.app.query_one(TabbedContent)
|
|
tab_count = len(list(content.query("TabPane")))
|
|
# AI + General + Plugins + fake plugin tab
|
|
assert tab_count == 4
|
|
|
|
|
|
async def test_q_key_exits_app(tmp_pyra_home):
|
|
from pyra.config.manager import save_config
|
|
from pyra.config.tui import ConfigApp
|
|
|
|
save_config(_make_cfg())
|
|
async with ConfigApp().run_test() as pilot:
|
|
await pilot.press("q")
|
|
# Reaching here means the app exited cleanly
|
|
|
|
|
|
async def test_ai_tab_renders_provider_fields(tmp_pyra_home):
|
|
from pyra.config.manager import save_config
|
|
from pyra.config.tui import ConfigApp
|
|
|
|
save_config(_make_cfg())
|
|
async with ConfigApp().run_test() as pilot:
|
|
assert pilot.app.query_one("#ai-provider", Select)
|
|
assert pilot.app.query_one("#ai-model", Input)
|
|
assert pilot.app.query_one("#ai-base-url", Input)
|
|
|
|
|
|
async def test_ai_tab_save_updates_config(tmp_pyra_home):
|
|
from textual.app import App as TextualApp, ComposeResult as CR
|
|
|
|
from pyra.config.manager import load_config, save_config as initial_save
|
|
from pyra.config.tui import _AITab
|
|
|
|
initial_save(_make_cfg())
|
|
|
|
class _TestApp(TextualApp):
|
|
def compose(self) -> CR:
|
|
yield _AITab()
|
|
|
|
saved = []
|
|
with patch("pyra.config.tui.save_config", side_effect=lambda c: saved.append(c) or None):
|
|
async with _TestApp().run_test() as pilot:
|
|
pilot.app.query_one("#ai-model", Input).value = "llama3:70b"
|
|
await pilot.pause()
|
|
await pilot.press("ctrl+s")
|
|
|
|
assert saved, "save_config was not called"
|
|
assert saved[-1].ai.model == "llama3:70b"
|
|
|
|
|
|
async def test_ai_tab_save_calls_set_key_when_provided(tmp_pyra_home):
|
|
from textual.app import App as TextualApp, ComposeResult as CR
|
|
|
|
from pyra.config.manager import save_config as initial_save
|
|
from pyra.config.tui import _AITab
|
|
|
|
initial_save(_make_cfg())
|
|
|
|
class _TestApp(TextualApp):
|
|
def compose(self) -> CR:
|
|
yield _AITab()
|
|
|
|
calls = []
|
|
with patch("pyra.config.tui.save_config"):
|
|
with patch("pyra.config.tui.set_key", side_effect=lambda p, k: calls.append((p, k))):
|
|
async with _TestApp().run_test() as pilot:
|
|
pilot.app.query_one("#ai-key", Input).value = "sk-test"
|
|
await pilot.pause()
|
|
await pilot.press("ctrl+s")
|
|
|
|
assert calls, "set_key was not called"
|
|
assert calls[-1][1] == "sk-test"
|
|
|
|
|
|
async def test_ai_tab_save_skips_set_key_when_empty(tmp_pyra_home):
|
|
from textual.app import App as TextualApp, ComposeResult as CR
|
|
|
|
from pyra.config.manager import save_config as initial_save
|
|
from pyra.config.tui import _AITab
|
|
|
|
initial_save(_make_cfg())
|
|
|
|
class _TestApp(TextualApp):
|
|
def compose(self) -> CR:
|
|
yield _AITab()
|
|
|
|
calls = []
|
|
with patch("pyra.config.tui.save_config"):
|
|
with patch("pyra.config.tui.set_key", side_effect=lambda p, k: calls.append((p, k))):
|
|
async with _TestApp().run_test() as pilot:
|
|
# Leave api-key empty (default)
|
|
await pilot.pause()
|
|
await pilot.press("ctrl+s")
|
|
|
|
assert not calls, "set_key should not be called when key input is empty"
|
|
|
|
|
|
async def test_general_tab_renders_section_headers(tmp_pyra_home):
|
|
from textual.app import App as TextualApp, ComposeResult as CR
|
|
|
|
from pyra.config.manager import save_config as initial_save
|
|
from pyra.config.tui import _GeneralTab
|
|
|
|
initial_save(_make_cfg())
|
|
|
|
class _TestApp(TextualApp):
|
|
def compose(self) -> CR:
|
|
yield _GeneralTab()
|
|
|
|
async with _TestApp().run_test() as pilot:
|
|
headers = list(pilot.app.query(".section-header"))
|
|
assert len(headers) >= 5, "Expected at least 5 section headers"
|