test: add coverage for config TUI, ConfigField, schema changes, and CLI auto-setup
- test_config.py: GeneralConfig defaults, plugin_settings round-trip - test_config_field.py: ConfigField dataclass, BasePlugin.config_fields() no-op, plugin subclass override - test_config_tui.py: _get/_set_nested, _fid/_pfid helpers, GENERAL_FIELDS validity, ConfigApp general tab rendering, save handler, plugins table, plugin tab visibility, q key exit — using Textual run_test() + Pilot - test_cli.py: auto-setup wizard on first run, skip wizard when config exists, /config in _STATIC_COMMANDS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -107,3 +107,33 @@ def test_daemon_commands_exit_cleanly(tmp_pyra_home):
|
||||
for cmd in ["start", "stop", "status", "restart"]:
|
||||
result = runner.invoke(main, ["daemon", cmd])
|
||||
assert result.exit_code == 0, f"daemon {cmd} exited with {result.exit_code}"
|
||||
|
||||
|
||||
def test_main_calls_setup_wizard_when_no_config(tmp_pyra_home, monkeypatch):
|
||||
setup_calls = []
|
||||
monkeypatch.setattr("pyra.setup.wizard.run_setup", lambda: setup_calls.append(1))
|
||||
monkeypatch.setattr("pyra.chat.session.start_chat", lambda: None)
|
||||
|
||||
runner = CliRunner()
|
||||
runner.invoke(main, [])
|
||||
|
||||
assert len(setup_calls) == 1, "run_setup should be called once when no config exists"
|
||||
|
||||
|
||||
def test_main_skips_setup_when_config_exists(tmp_pyra_home, monkeypatch):
|
||||
from pyra.config.manager import save_config
|
||||
save_config(_make_config())
|
||||
|
||||
setup_calls = []
|
||||
monkeypatch.setattr("pyra.setup.wizard.run_setup", lambda: setup_calls.append(1))
|
||||
monkeypatch.setattr("pyra.chat.session.start_chat", lambda: None)
|
||||
|
||||
runner = CliRunner()
|
||||
runner.invoke(main, [])
|
||||
|
||||
assert len(setup_calls) == 0, "run_setup should NOT be called when config already exists"
|
||||
|
||||
|
||||
def test_config_slash_command_registered():
|
||||
from pyra.chat.session import _STATIC_COMMANDS
|
||||
assert "/config" in _STATIC_COMMANDS
|
||||
|
||||
@@ -48,3 +48,42 @@ def test_load_config_missing_raises(tmp_pyra_home):
|
||||
from pyra.config.manager import load_config
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_config()
|
||||
|
||||
|
||||
def test_general_config_defaults():
|
||||
from pyra.config.schema import GeneralConfig
|
||||
g = GeneralConfig()
|
||||
assert g.user_name == "User"
|
||||
assert g.assistant_name == "Pyra"
|
||||
|
||||
|
||||
def test_pyraconfig_has_general_and_plugin_settings():
|
||||
cfg = PyraConfig(ai=ProviderConfig(provider_id="ollama", model="x"))
|
||||
assert cfg.general.user_name == "User"
|
||||
assert cfg.general.assistant_name == "Pyra"
|
||||
assert cfg.plugin_settings == {}
|
||||
|
||||
|
||||
def test_config_round_trip_preserves_general(tmp_pyra_home):
|
||||
from pyra.config.manager import save_config, load_config
|
||||
|
||||
cfg = PyraConfig(ai=ProviderConfig(provider_id="ollama", model="llama3"))
|
||||
cfg.general.user_name = "Alice"
|
||||
cfg.general.assistant_name = "Aria"
|
||||
save_config(cfg)
|
||||
|
||||
loaded = load_config()
|
||||
assert loaded.general.user_name == "Alice"
|
||||
assert loaded.general.assistant_name == "Aria"
|
||||
|
||||
|
||||
def test_config_round_trip_preserves_plugin_settings(tmp_pyra_home):
|
||||
from pyra.config.manager import save_config, load_config
|
||||
|
||||
cfg = PyraConfig(ai=ProviderConfig(provider_id="ollama", model="llama3"))
|
||||
cfg.plugin_settings["myplugin"] = {"api_url": "http://example.com", "verify_ssl": True}
|
||||
save_config(cfg)
|
||||
|
||||
loaded = load_config()
|
||||
assert loaded.plugin_settings["myplugin"]["api_url"] == "http://example.com"
|
||||
assert loaded.plugin_settings["myplugin"]["verify_ssl"] is True
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from pyra.plugins.base import BasePlugin, ConfigField
|
||||
|
||||
|
||||
def test_config_field_minimal():
|
||||
f = ConfigField("mykey", "My Label", "text")
|
||||
assert f.key == "mykey"
|
||||
assert f.label == "My Label"
|
||||
assert f.type == "text"
|
||||
assert f.default == ""
|
||||
assert f.options == []
|
||||
assert f.description == ""
|
||||
|
||||
|
||||
def test_config_field_options_are_independent():
|
||||
f1 = ConfigField("k", "L", "select")
|
||||
f2 = ConfigField("k", "L", "select")
|
||||
f1.options.append("x")
|
||||
assert f2.options == [], "options lists must not be shared between instances"
|
||||
|
||||
|
||||
def test_config_field_all_args():
|
||||
f = ConfigField("url", "API URL", "select", "http://a.com", ["opt1", "opt2"], "hint text")
|
||||
assert f.default == "http://a.com"
|
||||
assert f.options == ["opt1", "opt2"]
|
||||
assert f.description == "hint text"
|
||||
|
||||
|
||||
def test_config_field_bool_type():
|
||||
f = ConfigField("enabled", "Enable feature", "bool", True)
|
||||
assert f.type == "bool"
|
||||
assert f.default is True
|
||||
|
||||
|
||||
def test_base_plugin_config_fields_returns_empty():
|
||||
assert BasePlugin().config_fields() == []
|
||||
|
||||
|
||||
def test_plugin_subclass_config_fields_override():
|
||||
class MyPlugin(BasePlugin):
|
||||
name = "test"
|
||||
description = "test plugin"
|
||||
version = "1.0"
|
||||
|
||||
def config_fields(self):
|
||||
return [
|
||||
ConfigField("api_url", "API URL", "text", "http://example.com"),
|
||||
ConfigField("verify_ssl", "Verify SSL", "bool", True),
|
||||
]
|
||||
|
||||
fields = MyPlugin().config_fields()
|
||||
assert len(fields) == 2
|
||||
assert fields[0].key == "api_url"
|
||||
assert fields[0].type == "text"
|
||||
assert fields[1].key == "verify_ssl"
|
||||
assert fields[1].type == "bool"
|
||||
assert fields[1].default is True
|
||||
@@ -0,0 +1,164 @@
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from textual.widgets import DataTable, Input, 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"}
|
||||
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:
|
||||
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 click
|
||||
await pilot.click("#save-general")
|
||||
|
||||
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")))
|
||||
# General + Plugins + fake plugin tab
|
||||
assert tab_count == 3
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user