"""Tests for cli/main.py using click's CliRunner with in-memory-like temp DB.""" import json import tempfile from pathlib import Path import pytest from click.testing import CliRunner from cli.main import cli @pytest.fixture def runner(tmp_path): """CliRunner that uses a temp DB file.""" db_path = tmp_path / "test.db" return CliRunner(), ["--db", str(db_path)] def invoke(runner_tuple, args): runner, base = runner_tuple result = runner.invoke(cli, base + args) return result # -- project -- def test_project_add_and_list(runner): r = invoke(runner, ["project", "add", "vdol", "В долю поперёк", "~/projects/vdolipoperek", "--tech-stack", '["vue3","nuxt"]']) assert r.exit_code == 0 assert "vdol" in r.output r = invoke(runner, ["project", "list"]) assert r.exit_code == 0 assert "vdol" in r.output assert "В долю поперёк" in r.output def test_project_list_empty(runner): r = invoke(runner, ["project", "list"]) assert r.exit_code == 0 assert "No projects" in r.output def test_project_list_filter_status(runner): invoke(runner, ["project", "add", "a", "A", "/a", "--status", "active"]) invoke(runner, ["project", "add", "b", "B", "/b", "--status", "paused"]) r = invoke(runner, ["project", "list", "--status", "active"]) assert "a" in r.output assert "b" not in r.output def test_project_show(runner): invoke(runner, ["project", "add", "vdol", "В долю", "/vdol", "--tech-stack", '["vue3"]', "--priority", "2"]) r = invoke(runner, ["project", "show", "vdol"]) assert r.exit_code == 0 assert "vue3" in r.output assert "Priority: 2" in r.output def test_project_show_not_found(runner): r = invoke(runner, ["project", "show", "nope"]) assert r.exit_code == 1 assert "not found" in r.output # -- task -- def test_task_add_and_list(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) r = invoke(runner, ["task", "add", "p1", "Fix login bug", "--type", "debug"]) assert r.exit_code == 0 assert "P1-001" in r.output r = invoke(runner, ["task", "add", "p1", "Add search"]) assert "P1-002" in r.output r = invoke(runner, ["task", "list"]) assert "P1-001" in r.output assert "P1-002" in r.output def test_task_add_project_not_found(runner): r = invoke(runner, ["task", "add", "nope", "Some task"]) assert r.exit_code == 1 assert "not found" in r.output def test_task_list_filter(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) invoke(runner, ["project", "add", "p2", "P2", "/p2"]) invoke(runner, ["task", "add", "p1", "A"]) invoke(runner, ["task", "add", "p2", "B"]) r = invoke(runner, ["task", "list", "--project", "p1"]) assert "P1-001" in r.output assert "P2-001" not in r.output def test_task_show(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) invoke(runner, ["task", "add", "p1", "Fix bug", "--type", "debug"]) r = invoke(runner, ["task", "show", "P1-001"]) assert r.exit_code == 0 assert "Fix bug" in r.output def test_task_show_not_found(runner): r = invoke(runner, ["task", "show", "X-999"]) assert r.exit_code == 1 assert "not found" in r.output # -- decision -- def test_decision_add_and_list(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) r = invoke(runner, ["decision", "add", "p1", "gotcha", "Safari bug", "position:fixed breaks", "--category", "ui", "--tags", '["ios","css"]']) assert r.exit_code == 0 assert "gotcha" in r.output r = invoke(runner, ["decision", "list", "p1"]) assert "Safari bug" in r.output def test_decision_list_filter(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) invoke(runner, ["decision", "add", "p1", "gotcha", "A", "a", "--category", "ui"]) invoke(runner, ["decision", "add", "p1", "decision", "B", "b", "--category", "arch"]) r = invoke(runner, ["decision", "list", "p1", "--type", "gotcha"]) assert "A" in r.output assert "B" not in r.output # -- module -- def test_module_add_and_list(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) r = invoke(runner, ["module", "add", "p1", "search", "frontend", "src/search/", "--description", "Search UI"]) assert r.exit_code == 0 assert "search" in r.output r = invoke(runner, ["module", "list", "p1"]) assert "search" in r.output assert "Search UI" in r.output # -- status -- def test_status_all(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) invoke(runner, ["task", "add", "p1", "A"]) invoke(runner, ["task", "add", "p1", "B"]) r = invoke(runner, ["status"]) assert r.exit_code == 0 assert "p1" in r.output assert "2" in r.output # total tasks def test_status_single_project(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) invoke(runner, ["task", "add", "p1", "A"]) r = invoke(runner, ["status", "p1"]) assert r.exit_code == 0 assert "P1-001" in r.output assert "pending" in r.output def test_status_not_found(runner): r = invoke(runner, ["status", "nope"]) assert r.exit_code == 1 assert "not found" in r.output # -- cost -- def test_cost_empty(runner): r = invoke(runner, ["cost"]) assert r.exit_code == 0 assert "No agent runs" in r.output def test_cost_with_data(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) # Insert agent log directly via models (no CLI command for this) from core.db import init_db from core import models as m # Re-open the same DB the runner uses db_path = runner[1][1] conn = init_db(Path(db_path)) m.log_agent_run(conn, "p1", "dev", "implement", cost_usd=0.10, tokens_used=5000) conn.close() r = invoke(runner, ["cost", "--last", "7d"]) assert r.exit_code == 0 assert "p1" in r.output assert "$0.1000" in r.output # =========================================================================== # task update # =========================================================================== @pytest.mark.parametrize("status", ["pending", "in_progress", "review", "done", "blocked", "decomposed", "cancelled"]) def test_task_update_status(runner, status): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) invoke(runner, ["task", "add", "p1", "Fix bug"]) r = invoke(runner, ["task", "update", "P1-001", "--status", status]) assert r.exit_code == 0 assert status in r.output r = invoke(runner, ["task", "show", "P1-001"]) assert status in r.output def test_task_update_priority(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) invoke(runner, ["task", "add", "p1", "Fix bug"]) r = invoke(runner, ["task", "update", "P1-001", "--priority", "1"]) assert r.exit_code == 0 assert "priority=1" in r.output def test_task_update_not_found(runner): r = invoke(runner, ["task", "update", "NOPE", "--status", "done"]) assert r.exit_code != 0 def test_task_update_no_fields(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) invoke(runner, ["task", "add", "p1", "Fix bug"]) r = invoke(runner, ["task", "update", "P1-001"]) assert r.exit_code != 0 # =========================================================================== # hook # =========================================================================== def test_hook_add_and_list(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) r = invoke(runner, ["hook", "add", "--project", "p1", "--name", "rebuild", "--event", "pipeline_completed", "--command", "npm run build"]) assert r.exit_code == 0 assert "rebuild" in r.output assert "pipeline_completed" in r.output r = invoke(runner, ["hook", "list", "--project", "p1"]) assert r.exit_code == 0 assert "rebuild" in r.output assert "npm run build" in r.output def test_hook_add_with_module_path(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) r = invoke(runner, ["hook", "add", "--project", "p1", "--name", "fe-build", "--event", "pipeline_completed", "--command", "make build", "--module-path", "web/frontend/*", "--working-dir", "/tmp"]) assert r.exit_code == 0 r = invoke(runner, ["hook", "list", "--project", "p1"]) assert "web/frontend/*" in r.output def test_hook_add_project_not_found(runner): r = invoke(runner, ["hook", "add", "--project", "nope", "--name", "x", "--event", "pipeline_completed", "--command", "echo hi"]) assert r.exit_code == 1 assert "not found" in r.output def test_hook_list_empty(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) r = invoke(runner, ["hook", "list", "--project", "p1"]) assert r.exit_code == 0 assert "No hooks" in r.output def test_hook_remove(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) invoke(runner, ["hook", "add", "--project", "p1", "--name", "rebuild", "--event", "pipeline_completed", "--command", "make"]) r = invoke(runner, ["hook", "remove", "1"]) assert r.exit_code == 0 assert "Removed" in r.output r = invoke(runner, ["hook", "list", "--project", "p1"]) assert "No hooks" in r.output def test_hook_remove_not_found(runner): r = invoke(runner, ["hook", "remove", "999"]) assert r.exit_code == 1 assert "not found" in r.output def test_hook_logs_empty(runner): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) r = invoke(runner, ["hook", "logs", "--project", "p1"]) assert r.exit_code == 0 assert "No hook logs" in r.output def test_hook_setup_registers_rebuild_frontend(runner, tmp_path): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) r = invoke(runner, ["hook", "setup", "--project", "p1", "--scripts-dir", str(tmp_path)]) assert r.exit_code == 0 assert "rebuild-frontend" in r.output r = invoke(runner, ["hook", "list", "--project", "p1"]) assert r.exit_code == 0 assert "rebuild-frontend" in r.output assert "web/frontend/*" in r.output def test_hook_setup_idempotent(runner, tmp_path): invoke(runner, ["project", "add", "p1", "P1", "/p1"]) invoke(runner, ["hook", "setup", "--project", "p1", "--scripts-dir", str(tmp_path)]) r = invoke(runner, ["hook", "setup", "--project", "p1", "--scripts-dir", str(tmp_path)]) assert r.exit_code == 0 assert "already exists" in r.output r = invoke(runner, ["hook", "list", "--project", "p1"]) # Only one hook, not duplicated assert r.output.count("rebuild-frontend") == 1 def test_hook_setup_project_not_found(runner): r = invoke(runner, ["hook", "setup", "--project", "nope"]) assert r.exit_code == 1 assert "not found" in r.output