test_kanban_cli.py
1 """Tests for the kanban CLI surface (hermes_cli.kanban).""" 2 3 from __future__ import annotations 4 5 import argparse 6 import json 7 import os 8 from pathlib import Path 9 10 import pytest 11 12 from hermes_cli import kanban as kc 13 from hermes_cli import kanban_db as kb 14 15 16 @pytest.fixture 17 def kanban_home(tmp_path, monkeypatch): 18 home = tmp_path / ".hermes" 19 home.mkdir() 20 monkeypatch.setenv("HERMES_HOME", str(home)) 21 monkeypatch.setattr(Path, "home", lambda: tmp_path) 22 kb.init_db() 23 return home 24 25 26 # --------------------------------------------------------------------------- 27 # Workspace flag parsing 28 # --------------------------------------------------------------------------- 29 30 @pytest.mark.parametrize( 31 "value,expected", 32 [ 33 ("scratch", ("scratch", None)), 34 ("worktree", ("worktree", None)), 35 ("dir:/tmp/work", ("dir", "/tmp/work")), 36 ], 37 ) 38 def test_parse_workspace_flag_valid(value, expected): 39 assert kc._parse_workspace_flag(value) == expected 40 41 42 def test_parse_workspace_flag_expands_user(): 43 kind, path = kc._parse_workspace_flag("dir:~/vault") 44 assert kind == "dir" 45 assert path.endswith("/vault") 46 assert not path.startswith("~") 47 48 49 @pytest.mark.parametrize("bad", ["cloud", "dir:", "", "worktree:/x"]) 50 def test_parse_workspace_flag_rejects(bad): 51 if not bad: 52 # Empty -> defaults; not an error. 53 assert kc._parse_workspace_flag(bad) == ("scratch", None) 54 return 55 with pytest.raises(argparse.ArgumentTypeError): 56 kc._parse_workspace_flag(bad) 57 58 59 # --------------------------------------------------------------------------- 60 # run_slash smoke tests (end-to-end via the same entry both CLI and gateway use) 61 # --------------------------------------------------------------------------- 62 63 def test_run_slash_no_args_shows_usage(kanban_home): 64 out = kc.run_slash("") 65 assert "kanban" in out.lower() 66 assert "create" in out.lower() or "subcommand" in out.lower() or "action" in out.lower() 67 68 69 def test_run_slash_create_and_list(kanban_home): 70 out = kc.run_slash("create 'ship feature' --assignee alice") 71 assert "Created" in out 72 out = kc.run_slash("list") 73 assert "ship feature" in out 74 assert "alice" in out 75 76 77 def test_run_slash_create_with_parent_and_cascade(kanban_home): 78 # Parent then child via --parent 79 out1 = kc.run_slash("create 'parent' --assignee alice") 80 # Extract the "t_xxxx" id from "Created t_xxxx (ready, ...)" 81 import re 82 m = re.search(r"(t_[a-f0-9]+)", out1) 83 assert m 84 p = m.group(1) 85 out2 = kc.run_slash(f"create 'child' --assignee bob --parent {p}") 86 assert "todo" in out2 # child starts as todo 87 88 # Complete parent; list should promote child to ready 89 kc.run_slash(f"complete {p}") 90 # Explicit filter: child should now be ready (was todo before complete). 91 ready_list = kc.run_slash("list --status ready") 92 assert "child" in ready_list 93 94 95 def test_run_slash_show_includes_comments(kanban_home): 96 out = kc.run_slash("create 'x'") 97 import re 98 tid = re.search(r"(t_[a-f0-9]+)", out).group(1) 99 kc.run_slash(f"comment {tid} 'source is paywalled'") 100 show = kc.run_slash(f"show {tid}") 101 assert "source is paywalled" in show 102 103 104 def test_run_slash_block_unblock_cycle(kanban_home): 105 out = kc.run_slash("create 'x' --assignee alice") 106 import re 107 tid = re.search(r"(t_[a-f0-9]+)", out).group(1) 108 # Claim first so block() finds it running 109 kc.run_slash(f"claim {tid}") 110 assert "Blocked" in kc.run_slash(f"block {tid} 'need decision'") 111 assert "Unblocked" in kc.run_slash(f"unblock {tid}") 112 113 114 def test_run_slash_json_output(kanban_home): 115 out = kc.run_slash("create 'jsontask' --assignee alice --json") 116 payload = json.loads(out) 117 assert payload["title"] == "jsontask" 118 assert payload["assignee"] == "alice" 119 assert payload["status"] == "ready" 120 121 122 def test_run_slash_dispatch_dry_run_counts(kanban_home): 123 kc.run_slash("create 'a' --assignee alice") 124 kc.run_slash("create 'b' --assignee bob") 125 out = kc.run_slash("dispatch --dry-run") 126 assert "Spawned:" in out 127 128 129 def test_run_slash_context_output_format(kanban_home): 130 out = kc.run_slash("create 'tech spec' --assignee alice --body 'write an RFC'") 131 import re 132 tid = re.search(r"(t_[a-f0-9]+)", out).group(1) 133 kc.run_slash(f"comment {tid} 'remember to include performance section'") 134 ctx = kc.run_slash(f"context {tid}") 135 assert "tech spec" in ctx 136 assert "write an RFC" in ctx 137 assert "performance section" in ctx 138 139 140 def test_run_slash_tenant_filter(kanban_home): 141 kc.run_slash("create 'biz-a task' --tenant biz-a --assignee alice") 142 kc.run_slash("create 'biz-b task' --tenant biz-b --assignee alice") 143 a = kc.run_slash("list --tenant biz-a") 144 b = kc.run_slash("list --tenant biz-b") 145 assert "biz-a task" in a and "biz-b task" not in a 146 assert "biz-b task" in b and "biz-a task" not in b 147 148 149 def test_run_slash_usage_error_returns_message(kanban_home): 150 # Missing required argument for create 151 out = kc.run_slash("create") 152 assert "usage" in out.lower() or "error" in out.lower() 153 154 155 def test_run_slash_assign_reassigns(kanban_home): 156 out = kc.run_slash("create 'x' --assignee alice") 157 import re 158 tid = re.search(r"(t_[a-f0-9]+)", out).group(1) 159 assert "Assigned" in kc.run_slash(f"assign {tid} bob") 160 show = kc.run_slash(f"show {tid}") 161 assert "bob" in show 162 163 164 def test_run_slash_link_unlink(kanban_home): 165 a = kc.run_slash("create 'a'") 166 b = kc.run_slash("create 'b'") 167 import re 168 ta = re.search(r"(t_[a-f0-9]+)", a).group(1) 169 tb = re.search(r"(t_[a-f0-9]+)", b).group(1) 170 assert "Linked" in kc.run_slash(f"link {ta} {tb}") 171 # After link, b is todo 172 show = kc.run_slash(f"show {tb}") 173 assert "todo" in show 174 assert "Unlinked" in kc.run_slash(f"unlink {ta} {tb}") 175 176 177 # --------------------------------------------------------------------------- 178 # Integration with the COMMAND_REGISTRY 179 # --------------------------------------------------------------------------- 180 181 def test_kanban_is_resolvable(): 182 from hermes_cli.commands import resolve_command 183 184 cmd = resolve_command("kanban") 185 assert cmd is not None 186 assert cmd.name == "kanban" 187 188 189 def test_kanban_bypasses_active_session_guard(): 190 from hermes_cli.commands import should_bypass_active_session 191 192 assert should_bypass_active_session("kanban") 193 194 195 def test_kanban_in_autocomplete_table(): 196 from hermes_cli.commands import COMMANDS, SUBCOMMANDS 197 198 assert "/kanban" in COMMANDS 199 subs = SUBCOMMANDS.get("/kanban") or [] 200 assert "create" in subs 201 assert "dispatch" in subs 202 203 204 def test_kanban_not_gateway_only(): 205 # kanban is available in BOTH CLI and gateway surfaces. 206 from hermes_cli.commands import COMMAND_REGISTRY 207 208 cmd = next(c for c in COMMAND_REGISTRY if c.name == "kanban") 209 assert not cmd.cli_only 210 assert not cmd.gateway_only