kin/tests/test_cli.py

476 lines
16 KiB
Python
Raw Normal View History

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