kin/tests/test_cli.py

475 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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