diff --git a/CLAUDE.md b/CLAUDE.md index e027e4e..b11c559 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,6 +261,14 @@ chore: description ## Workflow Rules +### Testing + +- **Write tests for every new feature.** A feature without tests is incomplete — do not commit without them. +- New tests go in `tests/unit/` for pure-logic helpers and `tests/security/` for security-boundary code. +- All existing tests must continue to pass — run `pytest tests/ -v` before committing. +- Test pure functions directly; do not test interactive I/O (questionary, Rich output) — only test the logic helpers those flows call. +- For Rich output, capture side effects by monkeypatching `console.print` rather than using `capsys`. + ### Bugfixes - **Stay under 50 lines changed.** Find the root cause and fix it directly. diff --git a/tests/unit/test_chat_history.py b/tests/unit/test_chat_history.py index bd8db8a..7a6f06b 100644 --- a/tests/unit/test_chat_history.py +++ b/tests/unit/test_chat_history.py @@ -88,3 +88,70 @@ def test_trim_to_budget_no_trim_when_under_budget(): def test_trim_to_budget_empty_list(): from pyra.chat.history import _trim_to_budget assert _trim_to_budget([], 1000) == [] + + +# ── _build_system_base tests ─────────────────────────────────────────────────── + +def test_build_system_base_default_identity(): + from pyra.chat.history import _build_system_base + result = _build_system_base("User", "Pyra", "") + assert "You are Pyra" in result + assert "User" in result + + +def test_build_system_base_custom_name_and_assistant(): + from pyra.chat.history import _build_system_base + result = _build_system_base("Alice", "Aria", "") + assert "You are Aria" in result + assert "Alice" in result + + +def test_build_system_base_no_purpose_omits_focus_block(): + from pyra.chat.history import _build_system_base + result = _build_system_base("User", "Pyra", "") + assert "primary purpose" not in result + assert "not a general-purpose chatbot" not in result + + +def test_build_system_base_with_purpose_includes_focus_block(): + from pyra.chat.history import _build_system_base + result = _build_system_base("User", "Pyra", "manage my Nextcloud server") + assert "primary purpose" in result + assert "manage my Nextcloud server" in result + assert "not a general-purpose chatbot" in result + + +def test_build_system_base_always_includes_security_constraints(): + from pyra.chat.history import _build_system_base + for purpose in ("", "manage servers"): + result = _build_system_base("User", "Pyra", purpose) + assert "vault" in result + assert "shell commands" in result + + +def test_build_for_api_uses_config_user_name(tmp_pyra_home, monkeypatch): + monkeypatch.setattr("pyra.chat.history.load_context_for_session", lambda: "") + from pyra.config.schema import PyraConfig, ProviderConfig, GeneralConfig + from pyra.chat.history import ConversationHistory + cfg = PyraConfig( + ai=ProviderConfig(provider_id="lmstudio", model="gemma"), + general=GeneralConfig(user_name="Alice", assistant_name="Aria", purpose=""), + ) + h = ConversationHistory(cfg) + system = h.build_for_api()[0]["content"] + assert "Alice" in system + assert "Aria" in system + + +def test_build_for_api_uses_purpose(tmp_pyra_home, monkeypatch): + monkeypatch.setattr("pyra.chat.history.load_context_for_session", lambda: "") + from pyra.config.schema import PyraConfig, ProviderConfig, GeneralConfig + from pyra.chat.history import ConversationHistory + cfg = PyraConfig( + ai=ProviderConfig(provider_id="lmstudio", model="gemma"), + general=GeneralConfig(purpose="manage my home server"), + ) + h = ConversationHistory(cfg) + system = h.build_for_api()[0]["content"] + assert "manage my home server" in system + assert "primary purpose" in system diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 8b5dd51..b7deaef 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -55,6 +55,14 @@ def test_general_config_defaults(): g = GeneralConfig() assert g.user_name == "User" assert g.assistant_name == "Pyra" + assert g.purpose == "" + + +def test_general_config_purpose_roundtrip(): + from pyra.config.schema import GeneralConfig + cfg = GeneralConfig(user_name="Alice", purpose="manage servers") + assert cfg.user_name == "Alice" + assert cfg.purpose == "manage servers" def test_pyraconfig_has_general_and_plugin_settings(): @@ -70,11 +78,13 @@ def test_config_round_trip_preserves_general(tmp_pyra_home): cfg = PyraConfig(ai=ProviderConfig(provider_id="ollama", model="llama3")) cfg.general.user_name = "Alice" cfg.general.assistant_name = "Aria" + cfg.general.purpose = "manage my home server" save_config(cfg) loaded = load_config() assert loaded.general.user_name == "Alice" assert loaded.general.assistant_name == "Aria" + assert loaded.general.purpose == "manage my home server" def test_config_round_trip_preserves_plugin_settings(tmp_pyra_home): diff --git a/tests/unit/test_setup_wizard.py b/tests/unit/test_setup_wizard.py new file mode 100644 index 0000000..2c852e1 --- /dev/null +++ b/tests/unit/test_setup_wizard.py @@ -0,0 +1,92 @@ +"""Tests for setup wizard personalization helpers.""" + +import pytest + + +def test_use_case_plugin_mapping_all_categories_have_entries(): + from pyra.setup.wizard import _USE_CASE_PLUGINS + assert all(len(v) > 0 for v in _USE_CASE_PLUGINS.values()) + + +def test_use_case_plugin_mapping_has_expected_categories(): + from pyra.setup.wizard import _USE_CASE_PLUGINS + assert "Email" in _USE_CASE_PLUGINS + assert "Development & servers" in _USE_CASE_PLUGINS + assert "Research & web" in _USE_CASE_PLUGINS + + +def test_use_case_email_contains_email_plugin(): + from pyra.setup.wizard import _USE_CASE_PLUGINS + assert "email" in _USE_CASE_PLUGINS["Email"] + + +def test_use_case_dev_contains_ssh_and_docker(): + from pyra.setup.wizard import _USE_CASE_PLUGINS + assert "ssh_tool" in _USE_CASE_PLUGINS["Development & servers"] + assert "docker_tool" in _USE_CASE_PLUGINS["Development & servers"] + + +def test_use_case_file_management_contains_cloud_stores(): + from pyra.setup.wizard import _USE_CASE_PLUGINS + plugins = _USE_CASE_PLUGINS["File management"] + assert "gdrive" in plugins + assert "onedrive" in plugins + assert "dropbox_tool" in plugins + + +def test_suggest_plugins_empty_use_cases_returns_early(monkeypatch): + calls = [] + import pyra.setup.wizard as wiz + monkeypatch.setattr(wiz.console, "print", lambda *a, **kw: calls.append(a)) + wiz._suggest_plugins([]) + assert calls == [] + + +def test_suggest_plugins_unknown_use_case_returns_early(monkeypatch): + calls = [] + import pyra.setup.wizard as wiz + monkeypatch.setattr(wiz.console, "print", lambda *a, **kw: calls.append(a)) + wiz._suggest_plugins(["Not a real category"]) + assert calls == [] + + +def test_suggest_plugins_valid_use_case_calls_print(monkeypatch): + calls = [] + import pyra.setup.wizard as wiz + monkeypatch.setattr(wiz.console, "print", lambda *a, **kw: calls.append(str(a))) + wiz._suggest_plugins(["Email"]) + assert len(calls) > 0 + + +def test_suggest_plugins_panel_text_contains_plugin_name(monkeypatch): + from rich.panel import Panel + panels = [] + + import pyra.setup.wizard as wiz + + def capture_print(*args, **kwargs): + for a in args: + if isinstance(a, Panel): + panels.append(a.renderable) + + monkeypatch.setattr(wiz.console, "print", capture_print) + wiz._suggest_plugins(["Email"]) + assert any("email" in str(p) for p in panels) + + +def test_suggest_plugins_multiple_categories(monkeypatch): + from rich.panel import Panel + panels = [] + + import pyra.setup.wizard as wiz + + def capture_print(*args, **kwargs): + for a in args: + if isinstance(a, Panel): + panels.append(a.renderable) + + monkeypatch.setattr(wiz.console, "print", capture_print) + wiz._suggest_plugins(["Email", "Development & servers"]) + combined = " ".join(str(p) for p in panels) + assert "email" in combined + assert "ssh_tool" in combined