Commands: project (add/list/show), task (add/list/show), decision (add/list), module (add/list), status, cost. Auto-generated task IDs (PROJ-001). DB at ~/.kin/kin.db or $KIN_DB. pyproject.toml with `kin` entry point. 18 CLI tests, 39 total passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
207 lines
6 KiB
Python
207 lines
6 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
|