diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 23623ec..4b01d11 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -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 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 6de0f56..8b5dd51 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -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 diff --git a/tests/unit/test_config_field.py b/tests/unit/test_config_field.py new file mode 100644 index 0000000..37fdaa6 --- /dev/null +++ b/tests/unit/test_config_field.py @@ -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 diff --git a/tests/unit/test_config_tui.py b/tests/unit/test_config_tui.py new file mode 100644 index 0000000..f2b8025 --- /dev/null +++ b/tests/unit/test_config_tui.py @@ -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