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 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"))) # 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