475 lines
16 KiB
Python
475 lines
16 KiB
Python
"""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
|
||
# KIN-050: trigger_module_path должен быть NULL — хук срабатывает безусловно
|
||
assert "web/frontend/*" not 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
|
||
|
||
|
||
# ===========================================================================
|
||
# KIN-018 — project set-mode / task update --mode / show with mode labels
|
||
# ===========================================================================
|
||
|
||
def test_project_set_mode_auto(runner):
|
||
"""project set-mode auto — обновляет режим, выводит подтверждение."""
|
||
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
|
||
r = invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
|
||
assert r.exit_code == 0
|
||
assert "auto" in r.output
|
||
|
||
|
||
def test_project_set_mode_review(runner):
|
||
"""project set-mode review — обновляет режим обратно в review."""
|
||
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
|
||
invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
|
||
r = invoke(runner, ["project", "set-mode", "--project", "p1", "review"])
|
||
assert r.exit_code == 0
|
||
assert "review" in r.output
|
||
|
||
|
||
def test_project_set_mode_persisted(runner):
|
||
"""После project set-mode режим сохраняется в БД и виден в project show."""
|
||
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
|
||
invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
|
||
|
||
r = invoke(runner, ["project", "show", "p1"])
|
||
assert r.exit_code == 0
|
||
assert "auto" in r.output
|
||
|
||
|
||
def test_project_set_mode_not_found(runner):
|
||
"""project set-mode для несуществующего проекта → exit code 1."""
|
||
r = invoke(runner, ["project", "set-mode", "--project", "nope", "auto"])
|
||
assert r.exit_code == 1
|
||
assert "not found" in r.output
|
||
|
||
|
||
def test_project_set_mode_invalid(runner):
|
||
"""project set-mode с недопустимым значением → ошибка click."""
|
||
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
|
||
r = invoke(runner, ["project", "set-mode", "--project", "p1", "turbo"])
|
||
assert r.exit_code != 0
|
||
|
||
|
||
def test_project_show_displays_mode(runner):
|
||
"""project show отображает строку Mode: ..."""
|
||
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
|
||
r = invoke(runner, ["project", "show", "p1"])
|
||
assert r.exit_code == 0
|
||
assert "Mode:" in r.output
|
||
|
||
|
||
def test_task_update_mode_auto(runner):
|
||
"""task update --mode auto задаёт execution_mode на задачу."""
|
||
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
|
||
invoke(runner, ["task", "add", "p1", "Fix bug"])
|
||
r = invoke(runner, ["task", "update", "P1-001", "--mode", "auto"])
|
||
assert r.exit_code == 0
|
||
assert "auto" in r.output
|
||
|
||
|
||
def test_task_update_mode_review(runner):
|
||
"""task update --mode review задаёт execution_mode=review на задачу."""
|
||
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
|
||
invoke(runner, ["task", "add", "p1", "Fix bug"])
|
||
r = invoke(runner, ["task", "update", "P1-001", "--mode", "review"])
|
||
assert r.exit_code == 0
|
||
assert "review" in r.output
|
||
|
||
|
||
def test_task_update_mode_persisted(runner):
|
||
"""После task update --mode режим сохраняется и виден в task show как (overridden)."""
|
||
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
|
||
invoke(runner, ["task", "add", "p1", "Fix bug"])
|
||
invoke(runner, ["task", "update", "P1-001", "--mode", "auto"])
|
||
|
||
r = invoke(runner, ["task", "show", "P1-001"])
|
||
assert r.exit_code == 0
|
||
assert "overridden" in r.output
|
||
|
||
|
||
def test_task_update_mode_invalid(runner):
|
||
"""task update --mode с недопустимым значением → ошибка click."""
|
||
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
|
||
invoke(runner, ["task", "add", "p1", "Fix bug"])
|
||
r = invoke(runner, ["task", "update", "P1-001", "--mode", "turbo"])
|
||
assert r.exit_code != 0
|
||
|
||
|
||
def test_task_show_mode_inherited(runner):
|
||
"""task show без явного execution_mode показывает (inherited)."""
|
||
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
|
||
invoke(runner, ["task", "add", "p1", "Fix bug"])
|
||
r = invoke(runner, ["task", "show", "P1-001"])
|
||
assert r.exit_code == 0
|
||
assert "inherited" in r.output
|
||
|
||
|
||
def test_task_show_mode_overridden(runner):
|
||
"""task show с task-level execution_mode показывает (overridden)."""
|
||
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
|
||
invoke(runner, ["task", "add", "p1", "Fix bug"])
|
||
invoke(runner, ["task", "update", "P1-001", "--mode", "review"])
|
||
r = invoke(runner, ["task", "show", "P1-001"])
|
||
assert r.exit_code == 0
|
||
assert "overridden" in r.output
|
||
|
||
|
||
def test_task_show_mode_label_reflects_project_mode(runner):
|
||
"""Если у проекта auto, у задачи нет mode — task show показывает 'auto (inherited)'."""
|
||
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
|
||
invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
|
||
invoke(runner, ["task", "add", "p1", "Fix bug"])
|
||
r = invoke(runner, ["task", "show", "P1-001"])
|
||
assert r.exit_code == 0
|
||
assert "auto" in r.output
|
||
assert "inherited" in r.output
|